Author SHA1 Message Date
host 8b2686548c fix: serialize concurrent PG schema init with advisory lock
ci / test (push) In progress
Startup race: concurrent init_db (cloud-server + worker) ran
CREATE TABLE / CREATE INDEX IF NOT EXISTS against a DB where the
object did not exist yet; the check-then-create is not atomic, so
both sessions created it and one died with UniqueViolation on
pg_type_typname_nsp_index (tables) or pg_class_relname_nsp_index
(indexes). Hit on the 37ba687 and 7983567 deploys (7983567 added
idx_tasks_parent); systemd restart healed it every time.

Fix (advisory lock, not catch-duplicate_object): init_postgres_schema
takes pg_advisory_xact_lock (key 20261108, released at its commit),
and server.init_db takes the session-level lock for the whole PG
branch so the _ensure_* objects outside POSTGRES_SCHEMA
(task_continue_messages, user_gateway_keys, task_attempts,
task_audit_loop_state) are covered too. Same idiom as the existing
RATE_LIMIT_SWEEP_ADVISORY_KEY sweep lock; key is distinct.

Tests: two new real-PG regression tests (skipped without
CLOUD_TEST_PG_DSN), 4 processes racing on an empty schema, for
init_postgres_schema and for full init_db; both fail on the
unfixed code and pass with the fix.
2026-11-08 05:49:52 +00:00
host 7983567491 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.
2026-11-07 20:51:23 +00:00
host f6df76ee8f feat: unify coordinator semantics (Muse role alignment phase 1)
Platform coordinator.py is the single source of truth; the skill
coordinator template (templates/roles/00-coordinator.md + SKILL.md
appendix 00 + body) now derives from it instead of describing a
second, model-played coordinator (U1-U13).

- server: _create_task accepts parent_task_id (404/400 checks,
  coordinator-under-coordinator rejected); follow-ups to a running
  coordinator return 409 at entry (_create_followup /
  _deliver_to_thread) with documented alternatives (U14/U15).
- tests: 8 new coordinator tests (depth limit, lineage, replay,
  follow-up 409); appendix sync/provenance/transplant suites green.
- check_appendix.py: 12 chapters match templates/roles/.
2026-11-07 19:43:03 +00:00
admin 834eced686 Merge pull request 'Docker Netfix B' (#2) from docker-netfix-b into main
ci / test (push) Successful in 31m47s
2026-11-06 23:51:04 +01:00
host 4d3ea048a5 docker: pin tenant networks to 172.16.0.0/16 via explicit --subnet
ci / test (push) Successful in 35m1s
Tenant networks were created bare (docker network create <name>),
letting Docker's default pool hand out subnets outside the iptables
8889 allowlist (172.16.0.0/12). The smoke tenant landed on
192.168.32.0/20, so containers never reached the egress proxy and
every model turn timed out at 600s twice.

Fix (B): derive a deterministic /24 inside 172.16.0.0/16 from
sha256(network name) with linear probing on IPAM overlap, max 32
probes. 172.16.0.0/16 is the one /16 in the allowlist Docker never
auto-assigns (default pool starts at 172.17.0.0/16). No persistent
state; capacity 256 tenant networks per host, explicit failure when
exhausted. Pre-existing out-of-range networks are reused as-is and
need manual removal (see README).

Tests: 7 new (candidate properties incl. wrap-around, pinned argv,
overlap probe, exhaustion, empty name).
2026-11-06 22:00:52 +00:00
admin d5642c6252 Merge pull request 'feat: wire native Codex UI as /console entry, keep classic console at /console-classic' (#1) from feat/codex-native-ui into main
ci / test (push) Successful in 37m47s
2026-11-06 21:19:18 +01:00
32 changed files with 1901 additions and 108 deletions

No files matched your search

+6 -1
View File
@@ -734,7 +734,12 @@ interrupted 且终态任务 cancel 返回 400、心跳后 sweep 不扫刚心跳
非租户模式完全一致(`/app/work`、codex-home、repos/<项目>、
test-repo),只是宿主挂载源按项目分开——容器内看不到其它
项目的任何文件;同时每个项目进独立的 Docker 网络
`codex-<项目>`(容器跨项目网络隔离),容器统一加
`codex-<项目>`(容器跨项目网络隔离),建网时显式
`--subnet` 把子网固定在 172.16.0.0/16 内(/24 按网络名
确定性划段),保证落在宿主 iptables 的代理放行范围
(172.16.0.0/12)内;2026-11-07 之前建好的范围外旧网
(如 codex-smoke-20261107)不会自动纠正,须人工删除后
重建。容器统一加
`--cap-drop=ALL --security-opt=no-new-privileges`。codex-home
只播种模板的 config.toml(relay 端点与 provider 名,不含
密钥值),绝不复制 thread_history。注意:内层 codex 沙箱仍
+7 -2
View File
@@ -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,
+5
View File
@@ -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;
+3
View File
@@ -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,
+15 -3
View File
@@ -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,
+7 -4
View File
@@ -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 (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);
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>
{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)} />
{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}
{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>
<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>
{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 ? <button onClick={() => ctx.setForkVisible(true)}>分支</button> : null}
</>)}
{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>;
+31 -31
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Codex 命令台</title>
<script type="module" crossorigin>(function(){const r=document.createElement("link").relList;if(r&&r.supports&&r.supports("modulepreload"))return;for(const y of document.querySelectorAll('link[rel="modulepreload"]'))f(y);new MutationObserver(y=>{for(const C of y)if(C.type==="childList")for(const A of C.addedNodes)A.tagName==="LINK"&&A.rel==="modulepreload"&&f(A)}).observe(document,{childList:!0,subtree:!0});function h(y){const C={};return y.integrity&&(C.integrity=y.integrity),y.referrerPolicy&&(C.referrerPolicy=y.referrerPolicy),y.crossOrigin==="use-credentials"?C.credentials="include":y.crossOrigin==="anonymous"?C.credentials="omit":C.credentials="same-origin",C}function f(y){if(y.ep)return;y.ep=!0;const C=h(y);fetch(y.href,C)}})();function Jh(i){return i&&i.__esModule&&Object.prototype.hasOwnProperty.call(i,"default")?i.default:i}var io={exports:{}},si={};/**
<script type="module" crossorigin>(function(){const r=document.createElement("link").relList;if(r&&r.supports&&r.supports("modulepreload"))return;for(const y of document.querySelectorAll('link[rel="modulepreload"]'))f(y);new MutationObserver(y=>{for(const A of y)if(A.type==="childList")for(const M of A.addedNodes)M.tagName==="LINK"&&M.rel==="modulepreload"&&f(M)}).observe(document,{childList:!0,subtree:!0});function h(y){const A={};return y.integrity&&(A.integrity=y.integrity),y.referrerPolicy&&(A.referrerPolicy=y.referrerPolicy),y.crossOrigin==="use-credentials"?A.credentials="include":y.crossOrigin==="anonymous"?A.credentials="omit":A.credentials="same-origin",A}function f(y){if(y.ep)return;y.ep=!0;const A=h(y);fetch(y.href,A)}})();function Jh(i){return i&&i.__esModule&&Object.prototype.hasOwnProperty.call(i,"default")?i.default:i}var co={exports:{}},ci={};/**
* @license React
* react-jsx-runtime.production.js
*
@@ -12,7 +12,7 @@
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var Dh;function Hy(){if(Dh)return si;Dh=1;var i=Symbol.for("react.transitional.element"),r=Symbol.for("react.fragment");function h(f,y,C){var A=null;if(C!==void 0&&(A=""+C),y.key!==void 0&&(A=""+y.key),"key"in y){C={};for(var S in y)S!=="key"&&(C[S]=y[S])}else C=y;return y=C.ref,{$$typeof:i,type:f,key:A,ref:y!==void 0?y:null,props:C}}return si.Fragment=r,si.jsx=h,si.jsxs=h,si}var Uh;function By(){return Uh||(Uh=1,io.exports=Hy()),io.exports}var c=By(),co={exports:{}},gt={};/**
*/var Dh;function Hy(){if(Dh)return ci;Dh=1;var i=Symbol.for("react.transitional.element"),r=Symbol.for("react.fragment");function h(f,y,A){var M=null;if(A!==void 0&&(M=""+A),y.key!==void 0&&(M=""+y.key),"key"in y){A={};for(var p in y)p!=="key"&&(A[p]=y[p])}else A=y;return y=A.ref,{$$typeof:i,type:f,key:M,ref:y!==void 0?y:null,props:A}}return ci.Fragment=r,ci.jsx=h,ci.jsxs=h,ci}var kh;function By(){return kh||(kh=1,co.exports=Hy()),co.exports}var c=By(),so={exports:{}},gt={};/**
* @license React
* react.production.js
*
@@ -20,7 +20,7 @@
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var wh;function qy(){if(wh)return gt;wh=1;var i=Symbol.for("react.transitional.element"),r=Symbol.for("react.portal"),h=Symbol.for("react.fragment"),f=Symbol.for("react.strict_mode"),y=Symbol.for("react.profiler"),C=Symbol.for("react.consumer"),A=Symbol.for("react.context"),S=Symbol.for("react.forward_ref"),_=Symbol.for("react.suspense"),M=Symbol.for("react.memo"),Y=Symbol.for("react.lazy"),x=Symbol.for("react.activity"),B=Symbol.for("react.view_transition"),V=Symbol.iterator;function et(g){return g===null||typeof g!="object"?null:(g=V&&g[V]||g["@@iterator"],typeof g=="function"?g:null)}var ft={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},F=Object.assign,yt={};function _t(g,H,st){this.props=g,this.context=H,this.refs=yt,this.updater=st||ft}_t.prototype.isReactComponent={},_t.prototype.setState=function(g,H){if(typeof g!="object"&&typeof g!="function"&&g!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,g,H,"setState")},_t.prototype.forceUpdate=function(g){this.updater.enqueueForceUpdate(this,g,"forceUpdate")};function kt(){}kt.prototype=_t.prototype;function Q(g,H,st){this.props=g,this.context=H,this.refs=yt,this.updater=st||ft}var dt=Q.prototype=new kt;dt.constructor=Q,F(dt,_t.prototype),dt.isPureReactComponent=!0;var R=Array.isArray;function X(){}var lt={H:null,A:null,T:null,S:null},pt=Object.prototype.hasOwnProperty;function Et(g,H,st){var ct=st.ref;return{$$typeof:i,type:g,key:H,ref:ct!==void 0?ct:null,props:st}}function mt(g,H){return Et(g.type,H,g.props)}function Rt(g){return typeof g=="object"&&g!==null&&g.$$typeof===i}function k(g){var H={"=":"=0",":":"=2"};return"$"+g.replace(/[=:]/g,function(st){return H[st]})}var ut=/\/+/g;function U(g,H){return typeof g=="object"&&g!==null&&g.key!=null?k(""+g.key):H.toString(36)}function G(g){switch(g.status){case"fulfilled":return g.value;case"rejected":throw g.reason;default:switch(typeof g.status=="string"?g.then(X,X):(g.status="pending",g.then(function(H){g.status==="pending"&&(g.status="fulfilled",g.value=H)},function(H){g.status==="pending"&&(g.status="rejected",g.reason=H)})),g.status){case"fulfilled":return g.value;case"rejected":throw g.reason}}throw g}function L(g,H,st,ct,Mt){var Nt=typeof g;(Nt==="undefined"||Nt==="boolean")&&(g=null);var wt=!1;if(g===null)wt=!0;else switch(Nt){case"bigint":case"string":case"number":wt=!0;break;case"object":switch(g.$$typeof){case i:case r:wt=!0;break;case Y:return wt=g._init,L(wt(g._payload),H,st,ct,Mt)}}if(wt)return Mt=Mt(g),wt=ct===""?"."+U(g,0):ct,R(Mt)?(st="",wt!=null&&(st=wt.replace(ut,"$&/")+"/"),L(Mt,H,st,"",function(re){return re})):Mt!=null&&(Rt(Mt)&&(Mt=mt(Mt,st+(Mt.key==null||g&&g.key===Mt.key?"":(""+Mt.key).replace(ut,"$&/")+"/")+wt)),H.push(Mt)),1;wt=0;var tt=ct===""?".":ct+":";if(R(g))for(var ht=0;ht<g.length;ht++)ct=g[ht],Nt=tt+U(ct,ht),wt+=L(ct,H,st,Nt,Mt);else if(ht=et(g),typeof ht=="function")for(g=ht.call(g),ht=0;!(ct=g.next()).done;)ct=ct.value,Nt=tt+U(ct,ht++),wt+=L(ct,H,st,Nt,Mt);else if(Nt==="object"){if(typeof g.then=="function")return L(G(g),H,st,ct,Mt);throw H=String(g),Error("Objects are not valid as a React child (found: "+(H==="[object Object]"?"object with keys {"+Object.keys(g).join(", ")+"}":H)+"). If you meant to render a collection of children, use an array instead.")}return wt}function it(g,H,st){if(g==null)return g;var ct=[],Mt=0;return L(g,ct,"","",function(Nt){return H.call(st,Nt,Mt++)}),ct}function jt(g){if(g._status===-1){var H=g._result,st=H();st.then(function(ct){(g._status===0||g._status===-1)&&(g._status=1,g._result=ct,st.status===void 0&&(st.status="fulfilled",st.value=ct))},function(ct){(g._status===0||g._status===-1)&&(g._status=2,g._result=ct,st.status===void 0&&(st.status="rejected",st.reason=ct))}),g._status===-1&&(g._status=0,g._result=st)}if(g._status===1)return g._result.default;throw g._result}var St=typeof reportError=="function"?reportError:function(g){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var H=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof g=="object"&&g!==null&&typeof g.message=="string"?String(g.message):String(g),error:g});if(!window.dispatchEvent(H))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",g);return}console.error(g)};function ye(g){var H=lt.T,st={};st.types=H!==null?H.types:null,lt.T=st;try{var ct=g(),Mt=lt.S;Mt!==null&&Mt(st,ct),typeof ct=="object"&&ct!==null&&typeof ct.then=="function"&&ct.then(X,St)}catch(Nt){St(Nt)}finally{H!==null&&st.types!==null&&(H.types=st.types),lt.T=H}}function we(g){var H=lt.T;if(H!==null){var st=H.types;st===null?H.types=[g]:st.indexOf(g)===-1&&st.push(g)}else ye(we.bind(null,g))}var ce={map:it,forEach:function(g,H,st){it(g,function(){H.apply(this,arguments)},st)},count:function(g){var H=0;return it(g,function(){H++}),H},Line truncated
*/var Uh;function qy(){if(Uh)return gt;Uh=1;var i=Symbol.for("react.transitional.element"),r=Symbol.for("react.portal"),h=Symbol.for("react.fragment"),f=Symbol.for("react.strict_mode"),y=Symbol.for("react.profiler"),A=Symbol.for("react.consumer"),M=Symbol.for("react.context"),p=Symbol.for("react.forward_ref"),_=Symbol.for("react.suspense"),R=Symbol.for("react.memo"),Q=Symbol.for("react.lazy"),b=Symbol.for("react.activity"),H=Symbol.for("react.view_transition"),Z=Symbol.iterator;function W(g){return g===null||typeof g!="object"?null:(g=Z&&g[Z]||g["@@iterator"],typeof g=="function"?g:null)}var ot={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},rt=Object.assign,bt={};function it(g,w,et){this.props=g,this.context=w,this.refs=bt,this.updater=et||ot}it.prototype.isReactComponent={},it.prototype.setState=function(g,w){if(typeof g!="object"&&typeof g!="function"&&g!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,g,w,"setState")},it.prototype.forceUpdate=function(g){this.updater.enqueueForceUpdate(this,g,"forceUpdate")};function wt(){}wt.prototype=it.prototype;function L(g,w,et){this.props=g,this.context=w,this.refs=bt,this.updater=et||ot}var ut=L.prototype=new wt;ut.constructor=L,rt(ut,it.prototype),ut.isPureReactComponent=!0;var O=Array.isArray;function V(){}var lt={H:null,A:null,T:null,S:null},St=Object.prototype.hasOwnProperty;function Nt(g,w,et){var nt=et.ref;return{$$typeof:i,type:g,key:w,ref:nt!==void 0?nt:null,props:et}}function mt(g,w){return Nt(g.type,w,g.props)}function Tt(g){return typeof g=="object"&&g!==null&&g.$$typeof===i}function q(g){var w={"=":"=0",":":"=2"};return"$"+g.replace(/[=:]/g,function(et){return w[et]})}var ht=/\/+/g;function _t(g,w){return typeof g=="object"&&g!==null&&g.key!=null?q(""+g.key):w.toString(36)}function G(g){switch(g.status){case"fulfilled":return g.value;case"rejected":throw g.reason;default:switch(typeof g.status=="string"?g.then(V,V):(g.status="pending",g.then(function(w){g.status==="pending"&&(g.status="fulfilled",g.value=w)},function(w){g.status==="pending"&&(g.status="rejected",g.reason=w)})),g.status){case"fulfilled":return g.value;case"rejected":throw g.reason}}throw g}function Y(g,w,et,nt,Mt){var Ct=typeof g;(Ct==="undefined"||Ct==="boolean")&&(g=null);var kt=!1;if(g===null)kt=!0;else switch(Ct){case"bigint":case"string":case"number":kt=!0;break;case"object":switch(g.$$typeof){case i:case r:kt=!0;break;case Q:return kt=g._init,Y(kt(g._payload),w,et,nt,Mt)}}if(kt)return Mt=Mt(g),kt=nt===""?"."+_t(g,0):nt,O(Mt)?(et="",kt!=null&&(et=kt.replace(ht,"$&/")+"/"),Y(Mt,w,et,"",function(Ne){return Ne})):Mt!=null&&(Tt(Mt)&&(Mt=mt(Mt,et+(Mt.key==null||g&&g.key===Mt.key?"":(""+Mt.key).replace(ht,"$&/")+"/")+kt)),w.push(Mt)),1;kt=0;var P=nt===""?".":nt+":";if(O(g))for(var ft=0;ft<g.length;ft++)nt=g[ft],Ct=P+_t(nt,ft),kt+=Y(nt,w,et,Ct,Mt);else if(ft=W(g),typeof ft=="function")for(g=ft.call(g),ft=0;!(nt=g.next()).done;)nt=nt.value,Ct=P+_t(nt,ft++),kt+=Y(nt,w,et,Ct,Mt);else if(Ct==="object"){if(typeof g.then=="function")return Y(G(g),w,et,nt,Mt);throw w=String(g),Error("Objects are not valid as a React child (found: "+(w==="[object Object]"?"object with keys {"+Object.keys(g).join(", ")+"}":w)+"). If you meant to render a collection of children, use an array instead.")}return kt}function z(g,w,et){if(g==null)return g;var nt=[],Mt=0;return Y(g,nt,"","",function(Ct){return w.call(et,Ct,Mt++)}),nt}function vt(g){if(g._status===-1){var w=g._result,et=w();et.then(function(nt){(g._status===0||g._status===-1)&&(g._status=1,g._result=nt,et.status===void 0&&(et.status="fulfilled",et.value=nt))},function(nt){(g._status===0||g._status===-1)&&(g._status=2,g._result=nt,et.status===void 0&&(et.status="rejected",et.reason=nt))}),g._status===-1&&(g._status=0,g._result=et)}if(g._status===1)return g._result.default;throw g._result}var pt=typeof reportError=="function"?reportError:function(g){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var w=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof g=="object"&&g!==null&&typeof g.message=="string"?String(g.message):String(g),error:g});if(!window.dispatchEvent(w))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",g);return}console.error(g)};function ee(g){var w=lt.T,et={};et.types=w!==null?w.types:null,lt.T=et;try{var nt=g(),Mt=lt.S;Mt!==null&&Mt(et,nt),typeof nt=="object"&&nt!==null&&typeof nt.then=="function"&&nt.then(V,pt)}catch(Ct){pt(Ct)}finally{w!==null&&et.types!==null&&(w.types=et.types),lt.T=w}}function fe(g){var w=lt.T;if(w!==null){var et=w.types;et===null?w.types=[g]:et.indexOf(g)===-1&&et.push(g)}else ee(fe.bind(null,g))}var ne={map:z,forEach:function(g,w,et){z(g,function(){w.apply(this,arguments)},et)},count:function(g){var w=0;return z(g,function(){w++}),w},toALine truncated
* @license React
* scheduler.production.js
*
@@ -28,7 +28,7 @@
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var Hh;function Gy(){return Hh||(Hh=1,(function(i){function r(G,L){var it=G.length;G.push(L);t:for(;0<it;){var jt=it-1>>>1,St=G[jt];if(0<y(St,L))G[jt]=L,G[it]=St,it=jt;else break t}}function h(G){return G.length===0?null:G[0]}function f(G){if(G.length===0)return null;var L=G[0],it=G.pop();if(it!==L){G[0]=it;t:for(var jt=0,St=G.length,ye=St>>>1;jt<ye;){var we=2*(jt+1)-1,ce=G[we],g=we+1,H=G[g];if(0>y(ce,it))g<St&&0>y(H,ce)?(G[jt]=H,G[g]=it,jt=g):(G[jt]=ce,G[we]=it,jt=we);else if(g<St&&0>y(H,it))G[jt]=H,G[g]=it,jt=g;else break t}}return L}function y(G,L){var it=G.sortIndex-L.sortIndex;return it!==0?it:G.id-L.id}if(i.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var C=performance;i.unstable_now=function(){return C.now()}}else{var A=Date,S=A.now();i.unstable_now=function(){return A.now()-S}}var _=[],M=[],Y=1,x=null,B=3,V=!1,et=!1,ft=!1,F=!1,yt=typeof setTimeout=="function"?setTimeout:null,_t=typeof clearTimeout=="function"?clearTimeout:null,kt=typeof setImmediate<"u"?setImmediate:null;function Q(G){for(var L=h(M);L!==null;){if(L.callback===null)f(M);else if(L.startTime<=G)f(M),L.sortIndex=L.expirationTime,r(_,L);else break;L=h(M)}}function dt(G){if(ft=!1,Q(G),!et)if(h(_)!==null)et=!0,R||(R=!0,Rt());else{var L=h(M);L!==null&&U(dt,L.startTime-G)}}var R=!1,X=-1,lt=5,pt=-1;function Et(){return F?!0:!(i.unstable_now()-pt<lt)}function mt(){if(F=!1,R){var G=i.unstable_now();pt=G;var L=!0;try{t:{et=!1,ft&&(ft=!1,_t(X),X=-1),V=!0;var it=B;try{e:{for(Q(G),x=h(_);x!==null&&!(x.expirationTime>G&&Et());){var jt=x.callback;if(typeof jt=="function"){x.callback=null,B=x.priorityLevel;var St=jt(x.expirationTime<=G);if(G=i.unstable_now(),typeof St=="function"){x.callback=St,Q(G),L=!0;break e}x===h(_)&&f(_),Q(G)}else f(_);x=h(_)}if(x!==null)L=!0;else{var ye=h(M);ye!==null&&U(dt,ye.startTime-G),L=!1}}break t}finally{x=null,B=it,V=!1}L=void 0}}finally{L?Rt():R=!1}}}var Rt;if(typeof kt=="function")Rt=function(){kt(mt)};else if(typeof MessageChannel<"u"){var k=new MessageChannel,ut=k.port2;k.port1.onmessage=mt,Rt=function(){ut.postMessage(null)}}else Rt=function(){yt(mt,0)};function U(G,L){X=yt(function(){G(i.unstable_now())},L)}i.unstable_IdlePriority=5,i.unstable_ImmediatePriority=1,i.unstable_LowPriority=4,i.unstable_NormalPriority=3,i.unstable_Profiling=null,i.unstable_UserBlockingPriority=2,i.unstable_cancelCallback=function(G){G.callback=null},i.unstable_forceFrameRate=function(G){0>G||125<G?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):lt=0<G?Math.floor(1e3/G):5},i.unstable_getCurrentPriorityLevel=function(){return B},i.unstable_next=function(G){switch(B){case 1:case 2:case 3:var L=3;break;default:L=B}var it=B;B=L;try{return G()}finally{B=it}},i.unstable_requestPaint=function(){F=!0},i.unstable_runWithPriority=function(G,L){switch(G){case 1:case 2:case 3:case 4:case 5:break;default:G=3}var it=B;B=G;try{return L()}finally{B=it}},i.unstable_scheduleCallback=function(G,L,it){var jt=i.unstable_now();switch(typeof it=="object"&&it!==null?(it=it.delay,it=typeof it=="number"&&0<it?jt+it:jt):it=jt,G){case 1:var St=-1;break;case 2:St=250;break;case 5:St=1073741823;break;case 4:St=1e4;break;default:St=5e3}return St=it+St,G={id:Y++,callback:L,priorityLevel:G,startTime:it,expirationTime:St,sortIndex:-1},it>jt?(G.sortIndex=it,r(M,G),h(_)===null&&G===h(M)&&(ft?(_t(X),X=-1):ft=!0,U(dt,it-jt))):(G.sortIndex=St,r(_,G),et||V||(et=!0,R||(R=!0,Rt()))),G},i.unstable_shouldYield=Et,i.unstable_wrapCallback=function(G){var L=B;return function(){var it=B;B=L;try{return G.apply(this,arguments)}finally{B=it}}}})(oo)),oo}var Bh;function Qy(){return Bh||(Bh=1,ro.exports=Gy()),ro.exports}var fo={exports:{}},Ue={};/**
*/var Hh;function Gy(){return Hh||(Hh=1,(function(i){function r(G,Y){var z=G.length;G.push(Y);t:for(;0<z;){var vt=z-1>>>1,pt=G[vt];if(0<y(pt,Y))G[vt]=Y,G[z]=pt,z=vt;else break t}}function h(G){return G.length===0?null:G[0]}function f(G){if(G.length===0)return null;var Y=G[0],z=G.pop();if(z!==Y){G[0]=z;t:for(var vt=0,pt=G.length,ee=pt>>>1;vt<ee;){var fe=2*(vt+1)-1,ne=G[fe],g=fe+1,w=G[g];if(0>y(ne,z))g<pt&&0>y(w,ne)?(G[vt]=w,G[g]=z,vt=g):(G[vt]=ne,G[fe]=z,vt=fe);else if(g<pt&&0>y(w,z))G[vt]=w,G[g]=z,vt=g;else break t}}return Y}function y(G,Y){var z=G.sortIndex-Y.sortIndex;return z!==0?z:G.id-Y.id}if(i.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var A=performance;i.unstable_now=function(){return A.now()}}else{var M=Date,p=M.now();i.unstable_now=function(){return M.now()-p}}var _=[],R=[],Q=1,b=null,H=3,Z=!1,W=!1,ot=!1,rt=!1,bt=typeof setTimeout=="function"?setTimeout:null,it=typeof clearTimeout=="function"?clearTimeout:null,wt=typeof setImmediate<"u"?setImmediate:null;function L(G){for(var Y=h(R);Y!==null;){if(Y.callback===null)f(R);else if(Y.startTime<=G)f(R),Y.sortIndex=Y.expirationTime,r(_,Y);else break;Y=h(R)}}function ut(G){if(ot=!1,L(G),!W)if(h(_)!==null)W=!0,O||(O=!0,Tt());else{var Y=h(R);Y!==null&&_t(ut,Y.startTime-G)}}var O=!1,V=-1,lt=5,St=-1;function Nt(){return rt?!0:!(i.unstable_now()-St<lt)}function mt(){if(rt=!1,O){var G=i.unstable_now();St=G;var Y=!0;try{t:{W=!1,ot&&(ot=!1,it(V),V=-1),Z=!0;var z=H;try{e:{for(L(G),b=h(_);b!==null&&!(b.expirationTime>G&&Nt());){var vt=b.callback;if(typeof vt=="function"){b.callback=null,H=b.priorityLevel;var pt=vt(b.expirationTime<=G);if(G=i.unstable_now(),typeof pt=="function"){b.callback=pt,L(G),Y=!0;break e}b===h(_)&&f(_),L(G)}else f(_);b=h(_)}if(b!==null)Y=!0;else{var ee=h(R);ee!==null&&_t(ut,ee.startTime-G),Y=!1}}break t}finally{b=null,H=z,Z=!1}Y=void 0}}finally{Y?Tt():O=!1}}}var Tt;if(typeof wt=="function")Tt=function(){wt(mt)};else if(typeof MessageChannel<"u"){var q=new MessageChannel,ht=q.port2;q.port1.onmessage=mt,Tt=function(){ht.postMessage(null)}}else Tt=function(){bt(mt,0)};function _t(G,Y){V=bt(function(){G(i.unstable_now())},Y)}i.unstable_IdlePriority=5,i.unstable_ImmediatePriority=1,i.unstable_LowPriority=4,i.unstable_NormalPriority=3,i.unstable_Profiling=null,i.unstable_UserBlockingPriority=2,i.unstable_cancelCallback=function(G){G.callback=null},i.unstable_forceFrameRate=function(G){0>G||125<G?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):lt=0<G?Math.floor(1e3/G):5},i.unstable_getCurrentPriorityLevel=function(){return H},i.unstable_next=function(G){switch(H){case 1:case 2:case 3:var Y=3;break;default:Y=H}var z=H;H=Y;try{return G()}finally{H=z}},i.unstable_requestPaint=function(){rt=!0},i.unstable_runWithPriority=function(G,Y){switch(G){case 1:case 2:case 3:case 4:case 5:break;default:G=3}var z=H;H=G;try{return Y()}finally{H=z}},i.unstable_scheduleCallback=function(G,Y,z){var vt=i.unstable_now();switch(typeof z=="object"&&z!==null?(z=z.delay,z=typeof z=="number"&&0<z?vt+z:vt):z=vt,G){case 1:var pt=-1;break;case 2:pt=250;break;case 5:pt=1073741823;break;case 4:pt=1e4;break;default:pt=5e3}return pt=z+pt,G={id:Q++,callback:Y,priorityLevel:G,startTime:z,expirationTime:pt,sortIndex:-1},z>vt?(G.sortIndex=z,r(R,G),h(_)===null&&G===h(R)&&(ot?(it(V),V=-1):ot=!0,_t(ut,z-vt))):(G.sortIndex=pt,r(_,G),W||Z||(W=!0,O||(O=!0,Tt()))),G},i.unstable_shouldYield=Nt,i.unstable_wrapCallback=function(G){var Y=H;return function(){var z=H;H=Y;try{return G.apply(this,arguments)}finally{H=z}}}})(fo)),fo}var Bh;function Qy(){return Bh||(Bh=1,oo.exports=Gy()),oo.exports}var mo={exports:{}},ke={};/**
* @license React
* react-dom.production.js
*
@@ -36,7 +36,7 @@
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var qh;function Xy(){if(qh)return Ue;qh=1;var i=go();function r(Y){var x="https://react.dev/errors/"+Y;if(1<arguments.length){x+="?args[]="+encodeURIComponent(arguments[1]);for(var B=2;B<arguments.length;B++)x+="&args[]="+encodeURIComponent(arguments[B])}return"Minified React error #"+Y+"; visit "+x+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function h(){}var f={d:{f:h,r:function(){throw Error(r(522))},D:h,C:h,L:h,m:h,X:h,S:h,M:h},p:0,findDOMNode:null},y=Symbol.for("react.portal"),C=Symbol.for("react.recoverable"),A=Symbol.for("react.optimistic_key");function S(Y,x,B){var V=3<arguments.length&&arguments[3]!==void 0?arguments[3]:null;return{$$typeof:y,key:V==null?null:V===A?A:""+V,children:Y,containerInfo:x,implementation:B}}var _=i.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;function M(Y,x){if(Y==="font")return"";if(typeof x=="string")return x==="use-credentials"?x:""}return Ue.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=f,Ue.browser=function(Y){return{$$typeof:C,_reason:Y}},Ue.createPortal=function(Y,x){var B=2<arguments.length&&arguments[2]!==void 0?arguments[2]:null;if(!x||x.nodeType!==1&&x.nodeType!==9&&x.nodeType!==11)throw Error(r(299));return S(Y,x,null,B)},Ue.flushSync=function(Y){var x=_.T,B=f.p;try{if(_.T=null,f.p=2,Y)return Y()}finally{_.T=x,f.p=B,f.d.f()}},Ue.preconnect=function(Y,x){typeof Y=="string"&&(x?(x=x.crossOrigin,x=typeof x=="string"?x==="use-credentials"?x:"":void 0):x=null,f.d.C(Y,x))},Ue.prefetchDNS=function(Y){typeof Y=="string"&&f.d.D(Y)},Ue.preinit=function(Y,x){if(typeof Y=="string"&&x&&typeof x.as=="string"){var B=x.as,V=M(B,x.crossOrigin),et=typeof x.integrity=="string"?x.integrity:void 0,ft=typeof x.fetchPriority=="string"?x.fetchPriority:void 0;B==="style"?f.d.S(Y,typeof x.precedence=="string"?x.precedence:void 0,{crossOrigin:V,integrity:et,fetchPriority:ft}):B==="script"&&f.d.X(Y,{crossOrigin:V,integrity:et,fetchPriority:ft,nonce:typeof x.nonce=="string"?x.nonce:void 0})}},Ue.preinitModule=function(Y,x){if(typeof Y=="string")if(typeof x=="object"&&x!==null){if(x.as==null||x.as==="script"){var B=M(x.as,x.crossOrigin);f.d.M(Y,{crossOrigin:B,integrity:typeof x.integrity=="string"?x.integrity:void 0,nonce:typeof x.nonce=="string"?x.nonce:void 0,fetchPriority:typeof x.fetchPriority=="string"?x.fetchPriority:void 0})}}else x==null&&f.d.M(Y)},Ue.preload=function(Y,x){if(typeof Y=="string"&&typeof x=="object"&&x!==null&&typeof x.as=="string"){var B=x.as,V=M(B,x.crossOrigin);f.d.L(Y,B,{crossOrigin:V,integrity:typeof x.integrity=="string"?x.integrity:void 0,nonce:typeof x.nonce=="string"?x.nonce:void 0,type:typeof x.type=="string"?x.type:void 0,fetchPriority:typeof x.fetchPriority=="string"?x.fetchPriority:void 0,referrerPolicy:typeof x.referrerPolicy=="string"?x.referrerPolicy:void 0,imageSrcSet:typeof x.imageSrcSet=="string"?x.imageSrcSet:void 0,imageSizes:typeof x.imageSizes=="string"?x.imageSizes:void 0,media:typeof x.media=="string"?x.media:void 0})}},Ue.preloadModule=function(Y,x){if(typeof Y=="string")if(x){var B=M(x.as,x.crossOrigin);f.d.m(Y,{as:typeof x.as=="string"&&x.as!=="script"?x.as:void 0,crossOrigin:B,integrity:typeof x.integrity=="string"?x.integrity:void 0,nonce:typeof x.nonce=="string"?x.nonce:void 0,fetchPriority:typeof x.fetchPriority=="string"?x.fetchPriority:void 0})}else f.d.m(Y)},Ue.requestFormReset=function(Y){f.d.r(Y)},Ue.unstable_batchedUpdates=function(Y,x){return Y(x)},Ue.useFormState=function(Y,x,B){return _.H.useFormState(Y,x,B)},Ue.useFormStatus=function(){return _.H.useHostTransitionStatus()},Ue.version="19.3.1",Ue}var Yh;function Ly(){if(Yh)return fo.exports;Yh=1;function i(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(i)}catch(r){console.error(r)}}return i(),fo.exports=Xy(),fo.exports}/**
*/var qh;function Xy(){if(qh)return ke;qh=1;var i=po();function r(Q){var b="https://react.dev/errors/"+Q;if(1<arguments.length){b+="?args[]="+encodeURIComponent(arguments[1]);for(var H=2;H<arguments.length;H++)b+="&args[]="+encodeURIComponent(arguments[H])}return"Minified React error #"+Q+"; visit "+b+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function h(){}var f={d:{f:h,r:function(){throw Error(r(522))},D:h,C:h,L:h,m:h,X:h,S:h,M:h},p:0,findDOMNode:null},y=Symbol.for("react.portal"),A=Symbol.for("react.recoverable"),M=Symbol.for("react.optimistic_key");function p(Q,b,H){var Z=3<arguments.length&&arguments[3]!==void 0?arguments[3]:null;return{$$typeof:y,key:Z==null?null:Z===M?M:""+Z,children:Q,containerInfo:b,implementation:H}}var _=i.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;function R(Q,b){if(Q==="font")return"";if(typeof b=="string")return b==="use-credentials"?b:""}return ke.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=f,ke.browser=function(Q){return{$$typeof:A,_reason:Q}},ke.createPortal=function(Q,b){var H=2<arguments.length&&arguments[2]!==void 0?arguments[2]:null;if(!b||b.nodeType!==1&&b.nodeType!==9&&b.nodeType!==11)throw Error(r(299));return p(Q,b,null,H)},ke.flushSync=function(Q){var b=_.T,H=f.p;try{if(_.T=null,f.p=2,Q)return Q()}finally{_.T=b,f.p=H,f.d.f()}},ke.preconnect=function(Q,b){typeof Q=="string"&&(b?(b=b.crossOrigin,b=typeof b=="string"?b==="use-credentials"?b:"":void 0):b=null,f.d.C(Q,b))},ke.prefetchDNS=function(Q){typeof Q=="string"&&f.d.D(Q)},ke.preinit=function(Q,b){if(typeof Q=="string"&&b&&typeof b.as=="string"){var H=b.as,Z=R(H,b.crossOrigin),W=typeof b.integrity=="string"?b.integrity:void 0,ot=typeof b.fetchPriority=="string"?b.fetchPriority:void 0;H==="style"?f.d.S(Q,typeof b.precedence=="string"?b.precedence:void 0,{crossOrigin:Z,integrity:W,fetchPriority:ot}):H==="script"&&f.d.X(Q,{crossOrigin:Z,integrity:W,fetchPriority:ot,nonce:typeof b.nonce=="string"?b.nonce:void 0})}},ke.preinitModule=function(Q,b){if(typeof Q=="string")if(typeof b=="object"&&b!==null){if(b.as==null||b.as==="script"){var H=R(b.as,b.crossOrigin);f.d.M(Q,{crossOrigin:H,integrity:typeof b.integrity=="string"?b.integrity:void 0,nonce:typeof b.nonce=="string"?b.nonce:void 0,fetchPriority:typeof b.fetchPriority=="string"?b.fetchPriority:void 0})}}else b==null&&f.d.M(Q)},ke.preload=function(Q,b){if(typeof Q=="string"&&typeof b=="object"&&b!==null&&typeof b.as=="string"){var H=b.as,Z=R(H,b.crossOrigin);f.d.L(Q,H,{crossOrigin:Z,integrity:typeof b.integrity=="string"?b.integrity:void 0,nonce:typeof b.nonce=="string"?b.nonce:void 0,type:typeof b.type=="string"?b.type:void 0,fetchPriority:typeof b.fetchPriority=="string"?b.fetchPriority:void 0,referrerPolicy:typeof b.referrerPolicy=="string"?b.referrerPolicy:void 0,imageSrcSet:typeof b.imageSrcSet=="string"?b.imageSrcSet:void 0,imageSizes:typeof b.imageSizes=="string"?b.imageSizes:void 0,media:typeof b.media=="string"?b.media:void 0})}},ke.preloadModule=function(Q,b){if(typeof Q=="string")if(b){var H=R(b.as,b.crossOrigin);f.d.m(Q,{as:typeof b.as=="string"&&b.as!=="script"?b.as:void 0,crossOrigin:H,integrity:typeof b.integrity=="string"?b.integrity:void 0,nonce:typeof b.nonce=="string"?b.nonce:void 0,fetchPriority:typeof b.fetchPriority=="string"?b.fetchPriority:void 0})}else f.d.m(Q)},ke.requestFormReset=function(Q){f.d.r(Q)},ke.unstable_batchedUpdates=function(Q,b){return Q(b)},ke.useFormState=function(Q,b,H){return _.H.useFormState(Q,b,H)},ke.useFormStatus=function(){return _.H.useHostTransitionStatus()},ke.version="19.3.1",ke}var Yh;function Ly(){if(Yh)return mo.exports;Yh=1;function i(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(i)}catch(r){console.error(r)}}return i(),mo.exports=Xy(),mo.exports}/**
* @license React
* react-dom-client.production.js
*
@@ -44,37 +44,37 @@
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var Gh;function Vy(){if(Gh)return ri;Gh=1;var i=Qy(),r=go(),h=Ly();function f(t){var e="https://react.dev/errors/"+t;if(1<arguments.length){e+="?args[]="+encodeURIComponent(arguments[1]);for(var l=2;l<arguments.length;l++)e+="&args[]="+encodeURIComponent(arguments[l])}return"Minified React error #"+t+"; visit "+e+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function y(t){return!(!t||t.nodeType!==1&&t.nodeType!==9&&t.nodeType!==11)}function C(t){for(var e=t,l=e;l&&!l.alternate;)e=l,(e.flags&4098)!==0&&(t=e.return),l=e.return;for(;e.return;)e=e.return;return e.tag===3?t:null}function A(t){if(t.tag===13){var e=t.memoizedState;if(e===null&&(t=t.alternate,t!==null&&(e=t.memoizedState)),e!==null)return e.dehydrated}return null}function S(t){if(t.tag===31){var e=t.memoizedState;if(e===null&&(t=t.alternate,t!==null&&(e=t.memoizedState)),e!==null)return e.dehydrated}return null}function _(t){if(C(t)!==t)throw Error(f(188))}function M(t){var e=t.alternate;if(!e){if(e=C(t),e===null)throw Error(f(188));return e!==t?null:t}for(var l=t,a=e;;){var n=l.return;if(n===null)break;var u=n.alternate;if(u===null){if(a=n.return,a!==null){l=a;continue}break}if(n.child===u.child){for(u=n.child;u;){if(u===l)return _(n),t;if(u===a)return _(n),e;u=u.sibling}throw Error(f(188))}if(l.return!==a.return)l=n,a=u;else{for(var s=!1,o=n.child;o;){if(o===l){s=!0,l=n,a=u;break}if(o===a){s=!0,a=n,l=u;break}o=o.sibling}if(!s){for(o=u.child;o;){if(o===l){s=!0,l=u,a=n;break}if(o===a){s=!0,a=u,l=n;break}o=o.sibling}if(!s)throw Error(f(189))}}if(l.alternate!==a)throw Error(f(190))}if(l.tag!==3)throw Error(f(188));return l.stateNode.current===l?t:e}function Y(t){var e=t.tag;if(e===5||e===26||e===27||e===6)return t;for(t=t.child;t!==null;){if(e=Y(t),e!==null)return e;t=t.sibling}return null}function x(t,e,l,a,n,u){for(;t!==null;){if((t.tag===5||t.tag===27||t.tag===6)&&l(t,a,n,u)||(t.tag!==22||t.memoizedState===null)&&(e||t.tag!==5&&t.tag!==27)&&x(t.child,e,l,a,n,u))return!0;t=t.sibling}return!1}function B(t){for(t=t.return;t!==null;){if(t.tag===3||t.tag===5||t.tag===27)return t;t=t.return}return null}function V(t){var e=!1;for(t=t.return;t!==null&&(t.tag===4&&(e=!0),!(t.tag===3||t.tag===5||t.tag===27));)t=t.return;return e}function et(t){var e=[null,null],l=B(t);return l===null||ft(e,t,l.child,{foundSelf:!1}),e}function ft(t,e,l,a){for(;l!==null;){if(l===e)a.foundSelf=!0;else if(l.tag===5||l.tag===27||l.tag===6){if(a.foundSelf)return t[1]=l,!0;t[0]=l}else if((l.tag!==22||l.memoizedState===null)&&ft(t,e,l.child,a))return!0;l=l.sibling}return!1}function F(t){switch(t.tag){case 5:case 27:case 6:return t.stateNode;case 3:return t.stateNode.containerInfo;default:throw Error(f(559))}}var yt=null,_t=null;function kt(t,e,l){return t===l?!0:t===e?(yt=t,!0):!1}function Q(t,e,l){return t===l?(_t=t,!1):t===e?(_t!==null&&(yt=t),!0):!1}function dt(t){if(t===null)return null;do t=t===null?null:t.return;while(t&&t.tag!==5&&t.tag!==27&&t.tag!==3);return t||null}function R(t,e,l){for(var a=0,n=t;n;n=l(n))a++;n=0;for(var u=e;u;u=l(u))n++;for(;0<a-n;)t=l(t),a--;for(;0<n-a;)e=l(e),n--;for(;a--;){if(t===e||e!==null&&t===e.alternate)return t;t=l(t),e=l(e)}return null}var X=Object.assign,lt=Symbol.for("react.element"),pt=Symbol.for("react.transitional.element"),Et=Symbol.for("react.portal"),mt=Symbol.for("react.fragment"),Rt=Symbol.for("react.strict_mode"),k=Symbol.for("react.profiler"),ut=Symbol.for("react.consumer"),U=Symbol.for("react.context"),G=Symbol.for("react.forward_ref"),L=Symbol.for("react.suspense"),it=Symbol.for("react.suspense_list"),jt=Symbol.for("react.memo"),St=Symbol.for("react.lazy"),ye=Symbol.for("react.activity"),we=Symbol.for("react.legacy_hidden"),ce=Symbol.for("react.memo_cache_sentinel"),g=Symbol.for("react.view_transition"),H=Symbol.for("react.recoverable"),st=Symbol.iterator;function ct(t){return t===null||typeof t!="object"?null:(t=st&&t[st]||t["@@iterator"],typeof t=="function"?t:null)}var Mt=Symbol.for("react.client.reference");function Nt(t){if(t==null)return null;if(typeof t=="function")return t.$$typeof===Mt?null:t.displayName||t.name||null;if(typeof t=="string")return t;switch(t){case mt:return"Fragment";case k:return"Profiler";case Rt:return"StrictMode";case L:return"Suspense";case it:return"SuspenseList";case ye:return"Activity";case g:return"ViewTransition"}if(typeof t=="object")switch(t.$$typeof){case Et:return"Portal";case U:return t.displayName||"Context";case ut:return(t._context.displayName||"Context")+".Consumer";case G:var e=t.render;return t=t.displayName,t||(t=e.displayName||e.name||"",t=t!==""?"ForwardRef("+t+")":"ForwardRef"),t;case jt:return e=t.displayName||null,e!==null?e:Nt(t.type)||"Memo";case St:e=t._payload,t=t._init;try{return Nt(t(e))}catch{}}return null}var wt=Array.isArray,tt=r.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,ht=h.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,re={pending:!1,data:null,method:null,actioLine truncated
*/var Gh;function Vy(){if(Gh)return si;Gh=1;var i=Qy(),r=po(),h=Ly();function f(t){var e="https://react.dev/errors/"+t;if(1<arguments.length){e+="?args[]="+encodeURIComponent(arguments[1]);for(var l=2;l<arguments.length;l++)e+="&args[]="+encodeURIComponent(arguments[l])}return"Minified React error #"+t+"; visit "+e+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function y(t){return!(!t||t.nodeType!==1&&t.nodeType!==9&&t.nodeType!==11)}function A(t){for(var e=t,l=e;l&&!l.alternate;)e=l,(e.flags&4098)!==0&&(t=e.return),l=e.return;for(;e.return;)e=e.return;return e.tag===3?t:null}function M(t){if(t.tag===13){var e=t.memoizedState;if(e===null&&(t=t.alternate,t!==null&&(e=t.memoizedState)),e!==null)return e.dehydrated}return null}function p(t){if(t.tag===31){var e=t.memoizedState;if(e===null&&(t=t.alternate,t!==null&&(e=t.memoizedState)),e!==null)return e.dehydrated}return null}function _(t){if(A(t)!==t)throw Error(f(188))}function R(t){var e=t.alternate;if(!e){if(e=A(t),e===null)throw Error(f(188));return e!==t?null:t}for(var l=t,a=e;;){var n=l.return;if(n===null)break;var u=n.alternate;if(u===null){if(a=n.return,a!==null){l=a;continue}break}if(n.child===u.child){for(u=n.child;u;){if(u===l)return _(n),t;if(u===a)return _(n),e;u=u.sibling}throw Error(f(188))}if(l.return!==a.return)l=n,a=u;else{for(var s=!1,o=n.child;o;){if(o===l){s=!0,l=n,a=u;break}if(o===a){s=!0,a=n,l=u;break}o=o.sibling}if(!s){for(o=u.child;o;){if(o===l){s=!0,l=u,a=n;break}if(o===a){s=!0,a=u,l=n;break}o=o.sibling}if(!s)throw Error(f(189))}}if(l.alternate!==a)throw Error(f(190))}if(l.tag!==3)throw Error(f(188));return l.stateNode.current===l?t:e}function Q(t){var e=t.tag;if(e===5||e===26||e===27||e===6)return t;for(t=t.child;t!==null;){if(e=Q(t),e!==null)return e;t=t.sibling}return null}function b(t,e,l,a,n,u){for(;t!==null;){if((t.tag===5||t.tag===27||t.tag===6)&&l(t,a,n,u)||(t.tag!==22||t.memoizedState===null)&&(e||t.tag!==5&&t.tag!==27)&&b(t.child,e,l,a,n,u))return!0;t=t.sibling}return!1}function H(t){for(t=t.return;t!==null;){if(t.tag===3||t.tag===5||t.tag===27)return t;t=t.return}return null}function Z(t){var e=!1;for(t=t.return;t!==null&&(t.tag===4&&(e=!0),!(t.tag===3||t.tag===5||t.tag===27));)t=t.return;return e}function W(t){var e=[null,null],l=H(t);return l===null||ot(e,t,l.child,{foundSelf:!1}),e}function ot(t,e,l,a){for(;l!==null;){if(l===e)a.foundSelf=!0;else if(l.tag===5||l.tag===27||l.tag===6){if(a.foundSelf)return t[1]=l,!0;t[0]=l}else if((l.tag!==22||l.memoizedState===null)&&ot(t,e,l.child,a))return!0;l=l.sibling}return!1}function rt(t){switch(t.tag){case 5:case 27:case 6:return t.stateNode;case 3:return t.stateNode.containerInfo;default:throw Error(f(559))}}var bt=null,it=null;function wt(t,e,l){return t===l?!0:t===e?(bt=t,!0):!1}function L(t,e,l){return t===l?(it=t,!1):t===e?(it!==null&&(bt=t),!0):!1}function ut(t){if(t===null)return null;do t=t===null?null:t.return;while(t&&t.tag!==5&&t.tag!==27&&t.tag!==3);return t||null}function O(t,e,l){for(var a=0,n=t;n;n=l(n))a++;n=0;for(var u=e;u;u=l(u))n++;for(;0<a-n;)t=l(t),a--;for(;0<n-a;)e=l(e),n--;for(;a--;){if(t===e||e!==null&&t===e.alternate)return t;t=l(t),e=l(e)}return null}var V=Object.assign,lt=Symbol.for("react.element"),St=Symbol.for("react.transitional.element"),Nt=Symbol.for("react.portal"),mt=Symbol.for("react.fragment"),Tt=Symbol.for("react.strict_mode"),q=Symbol.for("react.profiler"),ht=Symbol.for("react.consumer"),_t=Symbol.for("react.context"),G=Symbol.for("react.forward_ref"),Y=Symbol.for("react.suspense"),z=Symbol.for("react.suspense_list"),vt=Symbol.for("react.memo"),pt=Symbol.for("react.lazy"),ee=Symbol.for("react.activity"),fe=Symbol.for("react.legacy_hidden"),ne=Symbol.for("react.memo_cache_sentinel"),g=Symbol.for("react.view_transition"),w=Symbol.for("react.recoverable"),et=Symbol.iterator;function nt(t){return t===null||typeof t!="object"?null:(t=et&&t[et]||t["@@iterator"],typeof t=="function"?t:null)}var Mt=Symbol.for("react.client.reference");function Ct(t){if(t==null)return null;if(typeof t=="function")return t.$$typeof===Mt?null:t.displayName||t.name||null;if(typeof t=="string")return t;switch(t){case mt:return"Fragment";case q:return"Profiler";case Tt:return"StrictMode";case Y:return"Suspense";case z:return"SuspenseList";case ee:return"Activity";case g:return"ViewTransition"}if(typeof t=="object")switch(t.$$typeof){case Nt:return"Portal";case _t:return t.displayName||"Context";case ht:return(t._context.displayName||"Context")+".Consumer";case G:var e=t.render;return t=t.displayName,t||(t=e.displayName||e.name||"",t=t!==""?"ForwardRef("+t+")":"ForwardRef"),t;case vt:return e=t.displayName||null,e!==null?e:Ct(t.type)||"Memo";case pt:e=t._payload,t=t._init;try{return Ct(t(e))}catch{}}return null}var kt=Array.isArray,P=r.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,ft=h.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,Ne={pending:!1,data:null,method:null,actionLine truncated
at`)?" (<anonymous>)":-1<l.stack.indexOf("@")?"@unknown:0:0":""}return`
`+He+t+oe}var fl=!1;function $l(t,e){if(!t||fl)return"";fl=!0;var l=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var a={DetermineComponentFrameRoot:function(){try{if(e){var z=function(){throw Error()};if(Object.defineProperty(z.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(z,[])}catch(Z){var p=Z}Reflect.construct(t,[],z)}else{try{z.call()}catch(Z){p=Z}z=!1;try{var E=Object.getOwnPropertyDescriptor(t.prototype,"props");Object.defineProperty(t.prototype,"props",{configurable:!0,set:function(){throw Error()}}),z=!0,new t}finally{z&&(E!==void 0?Object.defineProperty(t.prototype,"props",E):delete t.prototype.props)}}}else{try{throw Error()}catch(Z){p=Z}(z=t())&&typeof z.catch=="function"&&z.catch(function(){})}}catch(Z){if(Z&&p&&typeof Z.stack=="string")return[Z.stack,p.stack]}return[null,null]}};a.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var n=Object.getOwnPropertyDescriptor(a.DetermineComponentFrameRoot,"name");n&&n.configurable&&Object.defineProperty(a.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var u=a.DetermineComponentFrameRoot(),s=u[0],o=u[1];if(s&&o){var m=s.split(`
`),b=o.split(`
`);for(n=a=0;a<m.length&&!m[a].includes("DetermineComponentFrameRoot");)a++;for(;n<b.length&&!b[n].includes("DetermineComponentFrameRoot");)n++;if(a===m.length||n===b.length)for(a=m.length-1,n=b.length-1;1<=a&&0<=n&&m[a]!==b[n];)n--;for(;1<=a&&0<=n;a--,n--)if(m[a]!==b[n]){if(a!==1||n!==1)do if(a--,n--,0>n||m[a]!==b[n]){var N=`
`+m[a].replace(" at new "," at ");return t.displayName&&N.includes("<anonymous>")&&(N=N.replace("<anonymous>",t.displayName)),N}while(1<=a&&0<=n);break}}}finally{fl=!1,Error.prepareStackTrace=l}return(l=t?t.displayName||t.name:"")?fe(l):""}function ya(t,e){switch(t.tag){case 26:case 27:case 5:return fe(t.type);case 16:return fe("Lazy");case 13:return t.child!==e&&e!==null?fe("Suspense Fallback"):fe("Suspense");case 19:return fe("SuspenseList");case 0:case 15:return $l(t.type,!1);case 11:return $l(t.type.render,!1);case 1:return $l(t.type,!0);case 31:return fe("Activity");case 30:return fe("ViewTransition");default:return""}}function kl(t){try{var e="",l=null;do e+=ya(t,l),l=t,t=t.return;while(t);return e}catch(a){return`
`+xl+t+Dl}var me=!1;function il(t,e){if(!t||me)return"";me=!0;var l=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var a={DetermineComponentFrameRoot:function(){try{if(e){var k=function(){throw Error()};if(Object.defineProperty(k.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(k,[])}catch(K){var S=K}Reflect.construct(t,[],k)}else{try{k.call()}catch(K){S=K}k=!1;try{var E=Object.getOwnPropertyDescriptor(t.prototype,"props");Object.defineProperty(t.prototype,"props",{configurable:!0,set:function(){throw Error()}}),k=!0,new t}finally{k&&(E!==void 0?Object.defineProperty(t.prototype,"props",E):delete t.prototype.props)}}}else{try{throw Error()}catch(K){S=K}(k=t())&&typeof k.catch=="function"&&k.catch(function(){})}}catch(K){if(K&&S&&typeof K.stack=="string")return[K.stack,S.stack]}return[null,null]}};a.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var n=Object.getOwnPropertyDescriptor(a.DetermineComponentFrameRoot,"name");n&&n.configurable&&Object.defineProperty(a.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var u=a.DetermineComponentFrameRoot(),s=u[0],o=u[1];if(s&&o){var m=s.split(`
`),j=o.split(`
`);for(n=a=0;a<m.length&&!m[a].includes("DetermineComponentFrameRoot");)a++;for(;n<j.length&&!j[n].includes("DetermineComponentFrameRoot");)n++;if(a===m.length||n===j.length)for(a=m.length-1,n=j.length-1;1<=a&&0<=n&&m[a]!==j[n];)n--;for(;1<=a&&0<=n;a--,n--)if(m[a]!==j[n]){if(a!==1||n!==1)do if(a--,n--,0>n||m[a]!==j[n]){var C=`
`+m[a].replace(" at new "," at ");return t.displayName&&C.includes("<anonymous>")&&(C=C.replace("<anonymous>",t.displayName)),C}while(1<=a&&0<=n);break}}}finally{me=!1,Error.prepareStackTrace=l}return(l=t?t.displayName||t.name:"")?de(l):""}function kl(t,e){switch(t.tag){case 26:case 27:case 5:return de(t.type);case 16:return de("Lazy");case 13:return t.child!==e&&e!==null?de("Suspense Fallback"):de("Suspense");case 19:return de("SuspenseList");case 0:case 15:return il(t.type,!1);case 11:return il(t.type.render,!1);case 1:return il(t.type,!0);case 31:return de("Activity");case 30:return de("ViewTransition");default:return""}}function Fl(t){try{var e="",l=null;do e+=kl(t,l),l=t,t=t.return;while(t);return e}catch(a){return`
Error generating stack: `+a.message+`
`+a.stack}}var te=Object.prototype.hasOwnProperty,tl=i.unstable_scheduleCallback,Be=i.unstable_cancelCallback,Nl=i.unstable_shouldYield,dl=i.unstable_requestPaint,$t=i.unstable_now,Hl=i.unstable_getCurrentPriorityLevel,vn=i.unstable_ImmediatePriority,Va=i.unstable_UserBlockingPriority,be=i.unstable_NormalPriority,Il=i.unstable_LowPriority,ga=i.unstable_IdlePriority,fu=i.log,du=i.unstable_setDisableYieldValue,Pl=null,Ce=null;function ml(t){if(typeof fu=="function"&&du(t),Ce&&typeof Ce.setStrictMode=="function")try{Ce.setStrictMode(Pl,t)}catch{}}var Ae=Math.clz32?Math.clz32:vu,mu=Math.log,hu=Math.LN2;function vu(t){return t>>>=0,t===0?32:31-(mu(t)/hu|0)|0}var q=256,w=262144,P=4194304;function nt(t){var e=t&42;if(e!==0)return e;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return t&-t;case 262144:case 524288:case 1048576:case 2097152:return t&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function qt(t,e,l){var a=t.pendingLanes;if(a===0)return 0;var n=0,u=t.suspendedLanes,s=t.pingedLanes;t=t.warmLanes;var o=a&134217727;return o!==0?(a=o&~u,a!==0?n=nt(a):(s&=o,s!==0?n=nt(s):l||(l=o&~t,l!==0&&(n=nt(l))))):(o=a&~u,o!==0?n=nt(o):s!==0?n=nt(s):l||(l=a&~t,l!==0&&(n=nt(l)))),n===0?0:e!==0&&e!==n&&(e&u)===0&&(u=n&-n,l=e&-e,u>=l||u===32&&(l&4194048)!==0)?e:n}function hl(t,e){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&e)===0}function D(t,e){(e&8)!==0&&(e|=e&32);var l=t.entangledLanes;if(l!==0)for(t=t.entanglements,l&=e;0<l;){var a=31-Ae(l),n=1<<a;e|=t[a],l&=~n}return e}function K(t,e){switch(t){case 1:case 2:case 4:case 8:case 64:return e+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function J(){var t=P;return P<<=1,(P&62914560)===0&&(P=4194304),t}function Yt(t){for(var e=[],l=0;31>l;l++)e.push(t);return e}function xt(t,e){t.pendingLanes|=e,e!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function qe(t,e,l,a,n,u){var s=t.pendingLanes;t.pendingLanes=l,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=l,t.entangledLanes&=l,t.errorRecoveryDisabledLanes&=l,t.shellSuspendCounter=0;var o=t.entanglements,m=t.expirationTimes,b=t.hiddenUpdates;for(l=s&~l;0<l;){var N=31-Ae(l),z=1<<N;o[N]=0,m[N]=-1;var p=b[N];if(p!==null)for(b[N]=null,N=0;N<p.length;N++){var E=p[N];E!==null&&(E.lane&=-536870913)}l&=~z}a!==0&&Cl(t,a,0),u!==0&&n===0&&t.tag!==0&&(t.suspendedLanes|=u&~(s&~e))}function Cl(t,e,l){t.pendingLanes|=e,t.suspendedLanes&=~e;var a=31-Ae(e);t.entangledLanes|=e,t.entanglements[a]=t.entanglements[a]|1073741824|l&261930}function yn(t,e){var l=t.entangledLanes|=e;for(t=t.entanglements;l;){var a=31-Ae(l),n=1<<a;n&e|t[a]&e&&(t[a]|=e),l&=~n}}function ta(t,e){var l=e&-e;return l=(l&42)!==0?1:Bl(l),(l&(t.suspendedLanes|e))!==0?0:l}function Bl(t){switch(t){case 2:t=1;break;case 8:t=4;break;case 32:t=16;break;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:t=128;break;case 268435456:t=134217728;break;default:t=0}return t}function yu(t){return t&=-t,2<t?8<t?(t&134217727)!==0?32:268435456:8:2}function gu(){var t=ht.p;return t!==0?t:(t=window.event,t===void 0?32:Nh(t.type))}function Ke(t,e){var l=ht.p;try{return ht.p=t,e()}finally{ht.p=l}}var ne=Math.random().toString(36).slice(2),pe="__reactFiber$"+ne,Je="__reactProps$"+ne,gn="__reactContainer$"+ne,xo="__reactEvents$"+ne,Ph="__reactListeners$"+ne,tv="__reactHandles$"+ne,jo="__reactResources$"+ne,pu="__reactMarker$"+ne,fi="__reactLoad$"+ne;function di(t){delete t[pe],delete t[Je],delete t[Ph],delete t[tv]}function Za(t){var e;if(e=t[pe])return e;for(var l=t.parentNode;l;){if(e=l[gn]||l[pe]){if(l=e.alternate,e.child!==null||l!==null&&l.child!==null)for(t=ih(t);t!==null;){if(l=t[pe])return l;t=ih(t)}return e}t=l,l=t.parentNode}return null}function pn(t){if(t=t[pe]||t[gn]){var e=t.tag;if(e===5||e===6||e===13||e===31||e===26||e===27||e===3)return t}return null}function Su(t){var e=t.tag;if(e===5||e===26||e===27||e===6)return t.stateNode;throw Error(f(33))}function Sn(t){var e=t[jo];return e||(e=t[jo]={hoistableStyles:new Map,hoistableScripts:new Map}),e}function Te(t){t[pu]=!0}function bo(t){t[fi]=void 0}var To=new Set,_o={};function Ka(t,e){xn(t,e),xn(t+"Capture",e)}function xn(t,eLine truncated
`).replace(G0,"")}function Ym(t,e){return e=qm(e),qm(t)===e}function Kt(t,e,l,a,n,u){switch(l){case"children":if(typeof a=="string")e==="body"||e==="textarea"&&a===""||bn(t,a);else if(typeof a=="number"||typeof a=="bigint")e!=="body"&&bn(t,""+a);else return;break;case"className":hi(t,"class",a);break;case"tabIndex":hi(t,"tabindex",a);break;case"dir":case"role":case"viewBox":case"width":case"height":hi(t,l,a);break;case"style":Uo(t,a,u);return;case"data":if(e!=="object"){hi(t,"data",a);break}case"src":case"href":if(a===""&&(e!=="a"||l!=="href")){t.removeAttribute(l);break}if(a==null||typeof a=="function"||typeof a=="symbol"||typeof a=="boolean"){t.removeAttribute(l);break}a=vi(a),t.setAttribute(l,a);break;case"action":case"formAction":if(typeof a=="function"){t.setAttribute(l,"javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')");break}else typeof u=="function"&&(l==="formAction"?(e!=="input"&&Kt(t,e,"name",n.name,n,null),Kt(t,e,"formEncType",n.formEncType,n,null),Kt(t,e,"formMethod",n.formMethod,n,null),Kt(t,e,"formTarget",n.formTarget,n,null)):(Kt(t,e,"encType",n.encType,n,null),Kt(t,e,"method",n.method,n,null),Kt(t,e,"target",n.target,n,null)));if(a==null||typeof a=="symbol"||typeof a=="boolean"){t.removeAttribute(l);break}a=vi(a),t.setAttribute(l,a);break;case"onClick":a!=null&&(t.onclick=ql);return;case"onScroll":a!=null&&zt("scroll",t);return;case"onScrollEnd":a!=null&&zt("scrollend",t);return;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(f(61));if(l=a.__html,l!=null){if(n.children!=null)throw Error(f(60));(u!=null?u.__html:void 0)!==l&&(t.innerHTML=l)}}break;case"multiple":t.multiple=a&&typeof a!="function"&&typeof a!="symbol";break;case"muted":t.muted=a&&typeof a!="function"&&typeof a!="symbol";break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"defaultValue":case"defaultChecked":case"innerHTML":case"ref":break;case"autoFocus":break;case"xlinkHref":if(a==null||typeof a=="function"||typeof a=="boolean"||typeof a=="symbol"){t.removeAttribute("xlink:href");break}l=vi(a),t.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",l);break;case"contentEditable":case"spellCheck":case"draggable":case"value":case"autoReverse":case"externalResourcesRequired":case"focusable":case"preserveAlpha":a!=null&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(l,a):t.removeAttribute(l);break;case"inert":case"allowFullScreen":case"async":case"autoPlay":case"controls":case"credentialless":case"default":case"defer":case"disabled":case"disablePictureInPicture":case"disableRemotePlayback":case"formNoValidate":case"hidden":case"loop":case"noModule":case"noValidate":case"open":case"playsInline":case"readOnly":case"required":case"reversed":case"scoped":case"seamless":case"itemScope":a&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(l,""):t.removeAttribute(l);break;case"capture":case"download":a===!0?t.setAttribute(l,""):a!==!1&&a!=null&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(l,a):t.removeAttribute(l);break;case"cols":case"rows":case"size":case"span":a!=null&&typeof a!="function"&&typeof a!="symbol"&&!isNaN(a)&&1<=a?t.setAttribute(l,a):t.removeAttribute(l);break;case"rowSpan":case"start":a==null||typeof a=="function"||typeof a=="symbol"||isNaN(a)?t.removeAttribute(l):t.setAttribute(l,a);break;case"popover":zt("beforetoggle",t),zt("toggle",t),mi(t,"popover",a);break;case"xlinkActuate":ea(t,"http://www.w3.org/1999/xlink","xlink:actuate",a);break;case"xlinkArcrole":ea(t,"http://www.w3.org/1999/xlink","xlink:arcrole",a);break;case"xlinkRole":ea(t,"http://www.w3.org/1999/xlink","xlink:role",a);break;case"xlinkShow":ea(t,"http://www.w3.org/1999/xlink","xlink:show",a);break;case"xlinkTitle":ea(t,"http://www.w3.org/1999/xlink","xlink:title",a);break;case"xlinkType":ea(t,"http://www.w3.org/1999/xlink","xlink:type",a);break;case"xmlBase":ea(t,"http://www.w3.org/XML/1998/namespace","xml:base",a);break;case"xmlLang":ea(t,"http://www.w3.org/XML/1998/namespace","xml:lang",a);break;case"xmlSpace":ea(t,"http://www.w3.org/XML/1998/namespace","xml:space",a);break;case"is":mi(t,"is",a);break;case"innerText":case"textContent":return;default:if(!(2<l.length)||l[0]!=="o"&&l[0]!=="O"||l[1]!=="n"&&l[1]!=="N")l=iv.get(l)||l,mi(t,l,a);else return}Bt=!0}function Hr(t,e,l,a,n,u){switch(l){case"style":Uo(t,a,u);return;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(f(61));if(l=a.__html,l!=null){if(n.children!=null)throw Error(f(60));(u!=null?u.__html:void 0)!==l&&(t.innerHTML=l)}}break;case"children":if(typeof a=="string")bn(t,a);else if(typeof a=="number"||typeof a=="bigint")bn(t,""+a);else return;break;case"onScroll":a!=null&&zt("scroll",t);return;case"onScrollEnd":a!=null&&zt("scroLine truncated
`;Yt&&(qe+=Yt+`
`),xt&&(qe+=(Array.isArray(xt)?xt.join(`
`):String(xt))+`
`),qe+=JSON.stringify(J,null,2).slice(0,4e3),_l(qe),re("已返回登录信息,见下方")}catch(J){re("发起失败:"+J.message)}},[]),P=d.useCallback(async()=>{const D=et.trim();if(!D){V("请填要查配额的用户 id");return}try{const K=await $(W.adminQuotaUser(D));V("用户 "+D.slice(0,8)+":本月用量 "+iu(K.used_usd_micros)+" / 配额 "+iu(K.quota_usd_micros)+(K.remaining_usd_micros!==null&&K.remaining_usd_micros!==void 0?"(剩余 "+iu(K.remaining_usd_micros)+")":"")+" | 月份 "+(K.month||""))}catch(K){V(Dc(K))}},[et]),nt=d.useCallback(async(D,K)=>{const J=D.trim();if(!J){V("请填要设置配额的用户 id");return}let Yt;if(!K.trim())Yt=null;else{const xt=Number(K.trim());if(!Number.isFinite(xt)||xt<0){V("配额金额无效:请输入 >= 0 的美元数,留空表示不限额");return}Yt=Math.round(xt*1e6)}try{const xt=await $(W.adminQuotas,"POST",{user_id:J,quota_usd_micros:Yt});V("配额已设置:用户 "+J.slice(0,8)+":本月用量 "+iu(xt.used_usd_micros)+" / 配额 "+iu(xt.quota_usd_micros)+(xt.remaining_usd_micros!==null&&xt.remaining_usd_micros!==void 0?"(剩余 "+iu(xt.remaining_usd_micros)+")":"")+" | 月份 "+(xt.month||""))}catch(xt){V(Dc(xt))}},[]),qt=d.useCallback(async()=>{if(window.confirm("确定手动触发 sweep 吗?超阈值的陈旧 running 任务会被标为 interrupted。"))try{const K=(await $(W.adminSweep,"POST",{})).swept||[];V("sweep 完成,清理 "+K.length+" 个任务"+(K.length?":"+K.join("、"):""))}catch(D){V(Dc(D))}},[]),hl=d.useCallback(async()=>{try{const D=await $(W.adminBackup,"POST",{});V("备份完成:backend="+D.backend+" | 路径 "+D.path+" | 大小 "+D.size_bytes+" 字节 | 任务 "+D.task_count+" 行")}catch(D){V(Dc(D))}},[]);return{keySaved:i,keyState:h,quotaText:y,healthText:A,adminGate:_,adminBody:Y,adminResult:B,adminUserId:et,setAdminUserId:ft,projects:F,projectId:_t,setProjectId:kt,projectMsg:Q,githubRepos:R,workers:lt,workersSummary:Et,workersError:Rt,members:ut,memberMsg:G,memberError:it,skills:St,apps:we,mcpServers:g,skillMsg:st,skillError:Mt,mcpMsg:wt,mcpOauthMsg:ht,mcpOauthBox:La,envs:Ve,envCount:Qt,envMsg:Wl,envRepoLine:va,envRepoBar:Ul,envRepoMsg:El,envSelect:oe,setEnvSelect:fe,models:fl,modelCustomVisible:ya,refreshHealth:Be,loadQuota:Nl,probeAdmin:dl,refreshProjects:$t,loadGithubRepos:Hl,loadRelayModels:vn,refreshWorkers:Va,refreshEnvs:be,refreshMembers:Il,refreshSkills:ga,saveKeyInput:fu,clearKeyInput:du,createProject:Pl,createEnv:Ce,archiveEnv:ml,saveEnvRepo:Ae,inviteMember:mu,changeMemberRole:hu,removeMember:vu,reloadMcp:q,mcpOauth:w,adminQuota:P,adminSetQuota:nt,adminSweep:qt,adminBackup:hl}}const mo=20;function ag(){const[i,r]=d.useState([]),[h,f]=d.useState(""),[y,C]=d.useState(""),[A,S]=d.useState(""),[_,M]=d.useState(""),[Y,x]=d.useState(0),[B,V]=d.useState(0),[et,ft]=d.useState({}),F=d.useRef(0),yt=d.useRef({status:"",executor:"",q:""});yt.current={status:y,executor:A,q:_};const _t=d.useRef(""),kt=d.useCallback(async(ut,U,G)=>{const L=ut!==void 0?ut:U;if(!L){r([]),V(0),ft({});return}_t.current=L;const it=G!==void 0?G:F.current,jt=yt.current;try{const St=await $(W.tasksPage(L,mo,it*mo,jt.status,jt.executor,jt.q));r(St.tasks||[]),V(St.total??(St.tasks||[]).length),ft(St.status_counts||{}),f("")}catch(St){f("加载失败:"+St.message)}},[]),Q=d.useCallback(ut=>{const U=Math.max(0,ut);F.current=U,x(U)},[]),dt=d.useCallback(ut=>{Q(ut),kt(_t.current||void 0,void 0,ut)},[kt,Q]);d.useEffect(()=>{if(!_t.current)return;const ut=setTimeout(()=>{kt(_t.current,void 0,0)},300);return()=>clearTimeout(ut)},[y,A,_]);const R=d.useCallback(ut=>{C(ut),Q(0)},[Q]),X=d.useCallback(ut=>{S(ut),Q(0)},[Q]),lt=d.useCallback(ut=>{M(ut),Q(0)},[Q]),pt=d.useCallback(async ut=>{Q(0),await kt(ut,void 0,0)},[kt,Q]),Et=Math.max(1,Math.ceil(B/mo)),mt=B===0?"共 0 个任务":"共 "+B+" 个任务,第 "+(Y+1)+" / "+Et+" 页",Rt=i,k=d.useCallback(async(ut,U)=>{const G={...ut};U.length&&(G.upload_id=await tg(U));const L=await $(W.tasksCreate,"POST",G);return"任务已建:"+xe(L.id)},[]);return{tasks:i,taskError:h,statusFilter:y,setStatusFilter:R,executorFilter:A,setExecutorFilter:X,searchText:_,setSearchText:lt,filteredTasks:Rt,taskCountText:mt,refreshTasks:kt,refreshTasksFirstPage:pt,page:Y,totalPages:Et,taskTotal:B,taskStatusCounts:et,goToPage:dt,dispatch:k}}function ng(i,r){const[h,f]=d.useState(null),[y,C]=d.useState(""),[A,S]=d.useState(""),[_,M]=d.useState(""),[Y,x]=d.useState(""),[B,V]=d.useState(void 0),[et,ft]=d.useState(!1),[F,yt]=d.useState(""),[_t,kt]=d.useState([]),[Q,dt]=d.useState([]),[R,X]=d.useState(""),[lt,pt]=d.useState([]),[Et,mt]=d.useState(""),[Rt,k]=d.useState(""),[ut,U]=d.useState([]),[G,L]=d.useState(""),[it,jt]=d.useState(""),[St,ye]=d.useState(""),[we,ce]=d.useState(""),[g,H]=d.useState([]),[st,ct]=d.useState([]),[Mt,Nt]=d.useState(""),[wt,tt]=d.useState([]),[ht,re]=d.useState(""),[La,_l]=d.useState(""),[Ve,ae]=d.useState(""),[Qt,ge]=d.useState(""),[Wl,je]=d.useState([]),[va,Ze]=d.useState(""),[Ul,wl]=Line truncated
`+a.stack}}var ue=Object.prototype.hasOwnProperty,jl=i.unstable_scheduleCallback,Ul=i.unstable_cancelCallback,wl=i.unstable_shouldYield,cl=i.unstable_requestPaint,Xt=i.unstable_now,bl=i.unstable_getCurrentPriorityLevel,Tl=i.unstable_ImmediatePriority,_l=i.unstable_UserBlockingPriority,Se=i.unstable_NormalPriority,Wl=i.unstable_LowPriority,$l=i.unstable_IdlePriority,Il=i.log,du=i.unstable_setDisableYieldValue,Pl=null,Ce=null;function sl(t){if(typeof Il=="function"&&du(t),Ce&&typeof Ce.setStrictMode=="function")try{Ce.setStrictMode(Pl,t)}catch{}}var Ae=Math.clz32?Math.clz32:vu,mu=Math.log,hu=Math.LN2;function vu(t){return t>>>=0,t===0?32:31-(mu(t)/hu|0)|0}var pa=256,Sa=262144,xa=4194304;function B(t){var e=t&42;if(e!==0)return e;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return t&-t;case 262144:case 524288:case 1048576:case 2097152:return t&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function U(t,e,l){var a=t.pendingLanes;if(a===0)return 0;var n=0,u=t.suspendedLanes,s=t.pingedLanes;t=t.warmLanes;var o=a&134217727;return o!==0?(a=o&~u,a!==0?n=B(a):(s&=o,s!==0?n=B(s):l||(l=o&~t,l!==0&&(n=B(l))))):(o=a&~u,o!==0?n=B(o):s!==0?n=B(s):l||(l=a&~t,l!==0&&(n=B(l)))),n===0?0:e!==0&&e!==n&&(e&u)===0&&(u=n&-n,l=e&-e,u>=l||u===32&&(l&4194048)!==0)?e:n}function $(t,e){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&e)===0}function N(t,e){(e&8)!==0&&(e|=e&32);var l=t.entangledLanes;if(l!==0)for(t=t.entanglements,l&=e;0<l;){var a=31-Ae(l),n=1<<a;e|=t[a],l&=~n}return e}function X(t,e){switch(t){case 1:case 2:case 4:case 8:case 64:return e+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function tt(){var t=xa;return xa<<=1,(xa&62914560)===0&&(xa=4194304),t}function Ut(t){for(var e=[],l=0;31>l;l++)e.push(t);return e}function yt(t,e){t.pendingLanes|=e,e!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function Wt(t,e,l,a,n,u){var s=t.pendingLanes;t.pendingLanes=l,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=l,t.entangledLanes&=l,t.errorRecoveryDisabledLanes&=l,t.shellSuspendCounter=0;var o=t.entanglements,m=t.expirationTimes,j=t.hiddenUpdates;for(l=s&~l;0<l;){var C=31-Ae(l),k=1<<C;o[C]=0,m[C]=-1;var S=j[C];if(S!==null)for(j[C]=null,C=0;C<S.length;C++){var E=S[C];E!==null&&(E.lane&=-536870913)}l&=~k}a!==0&&rl(t,a,0),u!==0&&n===0&&t.tag!==0&&(t.suspendedLanes|=u&~(s&~e))}function rl(t,e,l){t.pendingLanes|=e,t.suspendedLanes&=~e;var a=31-Ae(e);t.entangledLanes|=e,t.entanglements[a]=t.entanglements[a]|1073741824|l&261930}function ja(t,e){var l=t.entangledLanes|=e;for(t=t.entanglements;l;){var a=31-Ae(l),n=1<<a;n&e|t[a]&e&&(t[a]|=e),l&=~n}}function oi(t,e){var l=e&-e;return l=(l&42)!==0?1:yu(l),(l&(t.suspendedLanes|e))!==0?0:l}function yu(t){switch(t){case 2:t=1;break;case 8:t=4;break;case 32:t=16;break;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:t=128;break;case 268435456:t=134217728;break;default:t=0}return t}function Fa(t){return t&=-t,2<t?8<t?(t&134217727)!==0?32:268435456:8:2}function ta(){var t=ft.p;return t!==0?t:(t=window.event,t===void 0?32:Nh(t.type))}function ea(t,e){var l=ft.p;try{return ft.p=t,e()}finally{ft.p=l}}var El=Math.random().toString(36).slice(2),he="__reactFiber$"+El,$t="__reactProps$"+El,xe="__reactContainer$"+El,fi="__reactEvents$"+El,Ph="__reactListeners$"+El,tv="__reactHandles$"+El,jo="__reactResources$"+El,gu="__reactMarker$"+El,di="__reactLoad$"+El;function mi(t){delete t[he],delete t[$t],delete t[Ph],delete t[tv]}function Wa(t){var e;if(e=t[he])return e;for(var l=t.parentNode;l;){if(e=l[xe]||l[he]){if(l=e.alternate,e.child!==null||l!==null&&l.child!==null)for(t=ih(t);t!==null;){if(l=t[he])return l;t=ih(t)}return e}t=l,l=t.parentNode}return null}function xn(t){if(t=t[he]||t[xe]){var e=t.tag;if(e===5||e===6||e===13||e===31||e===26||e===27||e===3)return t}return null}function pu(t){var e=t.tag;if(e===5||e===26||e===27||e===6)return t.stateNode;throw Error(f(33))}function jn(t){var e=t[jo];return e||(e=t[jo]={hoistableStyles:new Map,hoistableScripts:new Map}),e}function be(t){t[gu]=!0}function bo(t){t[di]=void 0}var To=new Set,_o={};function $a(t,e){bn(t,e),bn(t+"Capture",e)}function bn(t,e)Line truncated
`).replace(G0,"")}function Ym(t,e){return e=qm(e),qm(t)===e}function Zt(t,e,l,a,n,u){switch(l){case"children":if(typeof a=="string")e==="body"||e==="textarea"&&a===""||_n(t,a);else if(typeof a=="number"||typeof a=="bigint")e!=="body"&&_n(t,""+a);else return;break;case"className":vi(t,"class",a);break;case"tabIndex":vi(t,"tabindex",a);break;case"dir":case"role":case"viewBox":case"width":case"height":vi(t,l,a);break;case"style":ko(t,a,u);return;case"data":if(e!=="object"){vi(t,"data",a);break}case"src":case"href":if(a===""&&(e!=="a"||l!=="href")){t.removeAttribute(l);break}if(a==null||typeof a=="function"||typeof a=="symbol"||typeof a=="boolean"){t.removeAttribute(l);break}a=yi(a),t.setAttribute(l,a);break;case"action":case"formAction":if(typeof a=="function"){t.setAttribute(l,"javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')");break}else typeof u=="function"&&(l==="formAction"?(e!=="input"&&Zt(t,e,"name",n.name,n,null),Zt(t,e,"formEncType",n.formEncType,n,null),Zt(t,e,"formMethod",n.formMethod,n,null),Zt(t,e,"formTarget",n.formTarget,n,null)):(Zt(t,e,"encType",n.encType,n,null),Zt(t,e,"method",n.method,n,null),Zt(t,e,"target",n.target,n,null)));if(a==null||typeof a=="symbol"||typeof a=="boolean"){t.removeAttribute(l);break}a=yi(a),t.setAttribute(l,a);break;case"onClick":a!=null&&(t.onclick=Hl);return;case"onScroll":a!=null&&zt("scroll",t);return;case"onScrollEnd":a!=null&&zt("scrollend",t);return;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(f(61));if(l=a.__html,l!=null){if(n.children!=null)throw Error(f(60));(u!=null?u.__html:void 0)!==l&&(t.innerHTML=l)}}break;case"multiple":t.multiple=a&&typeof a!="function"&&typeof a!="symbol";break;case"muted":t.muted=a&&typeof a!="function"&&typeof a!="symbol";break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"defaultValue":case"defaultChecked":case"innerHTML":case"ref":break;case"autoFocus":break;case"xlinkHref":if(a==null||typeof a=="function"||typeof a=="boolean"||typeof a=="symbol"){t.removeAttribute("xlink:href");break}l=yi(a),t.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",l);break;case"contentEditable":case"spellCheck":case"draggable":case"value":case"autoReverse":case"externalResourcesRequired":case"focusable":case"preserveAlpha":a!=null&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(l,a):t.removeAttribute(l);break;case"inert":case"allowFullScreen":case"async":case"autoPlay":case"controls":case"credentialless":case"default":case"defer":case"disabled":case"disablePictureInPicture":case"disableRemotePlayback":case"formNoValidate":case"hidden":case"loop":case"noModule":case"noValidate":case"open":case"playsInline":case"readOnly":case"required":case"reversed":case"scoped":case"seamless":case"itemScope":a&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(l,""):t.removeAttribute(l);break;case"capture":case"download":a===!0?t.setAttribute(l,""):a!==!1&&a!=null&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(l,a):t.removeAttribute(l);break;case"cols":case"rows":case"size":case"span":a!=null&&typeof a!="function"&&typeof a!="symbol"&&!isNaN(a)&&1<=a?t.setAttribute(l,a):t.removeAttribute(l);break;case"rowSpan":case"start":a==null||typeof a=="function"||typeof a=="symbol"||isNaN(a)?t.removeAttribute(l):t.setAttribute(l,a);break;case"popover":zt("beforetoggle",t),zt("toggle",t),hi(t,"popover",a);break;case"xlinkActuate":la(t,"http://www.w3.org/1999/xlink","xlink:actuate",a);break;case"xlinkArcrole":la(t,"http://www.w3.org/1999/xlink","xlink:arcrole",a);break;case"xlinkRole":la(t,"http://www.w3.org/1999/xlink","xlink:role",a);break;case"xlinkShow":la(t,"http://www.w3.org/1999/xlink","xlink:show",a);break;case"xlinkTitle":la(t,"http://www.w3.org/1999/xlink","xlink:title",a);break;case"xlinkType":la(t,"http://www.w3.org/1999/xlink","xlink:type",a);break;case"xmlBase":la(t,"http://www.w3.org/XML/1998/namespace","xml:base",a);break;case"xmlLang":la(t,"http://www.w3.org/XML/1998/namespace","xml:lang",a);break;case"xmlSpace":la(t,"http://www.w3.org/XML/1998/namespace","xml:space",a);break;case"is":hi(t,"is",a);break;case"innerText":case"textContent":return;default:if(!(2<l.length)||l[0]!=="o"&&l[0]!=="O"||l[1]!=="n"&&l[1]!=="N")l=iv.get(l)||l,hi(t,l,a);else return}Bt=!0}function Br(t,e,l,a,n,u){switch(l){case"style":ko(t,a,u);return;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(f(61));if(l=a.__html,l!=null){if(n.children!=null)throw Error(f(60));(u!=null?u.__html:void 0)!==l&&(t.innerHTML=l)}}break;case"children":if(typeof a=="string")_n(t,a);else if(typeof a=="number"||typeof a=="bigint")_n(t,""+a);else return;break;case"onScroll":a!=null&&zt("scroll",t);return;case"onScrollEnd":a!=null&&zt("scroLine truncated
`;Ut&&(Wt+=Ut+`
`),yt&&(Wt+=(Array.isArray(yt)?yt.join(`
`):String(yt))+`
`),Wt+=JSON.stringify(tt,null,2).slice(0,4e3),Qe(Wt),Ne("已返回登录信息,见下方")}catch(tt){Ne("发起失败:"+tt.message)}},[]),xa=d.useCallback(async()=>{const N=W.trim();if(!N){Z("请填要查配额的用户 id");return}try{const X=await F(J.adminQuotaUser(N));Z("用户 "+N.slice(0,8)+":本月用量 "+su(X.used_usd_micros)+" / 配额 "+su(X.quota_usd_micros)+(X.remaining_usd_micros!==null&&X.remaining_usd_micros!==void 0?"(剩余 "+su(X.remaining_usd_micros)+")":"")+" | 月份 "+(X.month||""))}catch(X){Z(kc(X))}},[W]),B=d.useCallback(async(N,X)=>{const tt=N.trim();if(!tt){Z("请填要设置配额的用户 id");return}let Ut;if(!X.trim())Ut=null;else{const yt=Number(X.trim());if(!Number.isFinite(yt)||yt<0){Z("配额金额无效:请输入 >= 0 的美元数,留空表示不限额");return}Ut=Math.round(yt*1e6)}try{const yt=await F(J.adminQuotas,"POST",{user_id:tt,quota_usd_micros:Ut});Z("配额已设置:用户 "+tt.slice(0,8)+":本月用量 "+su(yt.used_usd_micros)+" / 配额 "+su(yt.quota_usd_micros)+(yt.remaining_usd_micros!==null&&yt.remaining_usd_micros!==void 0?"(剩余 "+su(yt.remaining_usd_micros)+")":"")+" | 月份 "+(yt.month||""))}catch(yt){Z(kc(yt))}},[]),U=d.useCallback(async()=>{if(window.confirm("确定手动触发 sweep 吗?超阈值的陈旧 running 任务会被标为 interrupted。"))try{const X=(await F(J.adminSweep,"POST",{})).swept||[];Z("sweep 完成,清理 "+X.length+" 个任务"+(X.length?":"+X.join("、"):""))}catch(N){Z(kc(N))}},[]),$=d.useCallback(async()=>{try{const N=await F(J.adminBackup,"POST",{});Z("备份完成:backend="+N.backend+" | 路径 "+N.path+" | 大小 "+N.size_bytes+" 字节 | 任务 "+N.task_count+" 行")}catch(N){Z(kc(N))}},[]);return{keySaved:i,keyState:h,quotaText:y,healthText:M,adminGate:_,adminBody:Q,adminResult:H,adminUserId:W,setAdminUserId:ot,projects:rt,projectId:it,setProjectId:wt,projectMsg:L,githubRepos:O,workers:lt,workersSummary:Nt,workersError:Tt,members:ht,memberMsg:G,memberError:z,skills:pt,apps:fe,mcpServers:g,skillMsg:et,skillError:Mt,mcpMsg:kt,mcpOauthMsg:ft,mcpOauthBox:Ja,envs:Xe,envCount:Gt,envMsg:Jl,envRepoLine:ya,envRepoBar:Rl,envRepoMsg:ga,envSelect:Dl,setEnvSelect:de,models:me,modelCustomVisible:kl,refreshHealth:Ul,loadQuota:wl,probeAdmin:cl,refreshProjects:Xt,loadGithubRepos:bl,loadRelayModels:Tl,refreshWorkers:_l,refreshEnvs:Se,refreshMembers:Wl,refreshSkills:$l,saveKeyInput:Il,clearKeyInput:du,createProject:Pl,createEnv:Ce,archiveEnv:sl,saveEnvRepo:Ae,inviteMember:mu,changeMemberRole:hu,removeMember:vu,reloadMcp:pa,mcpOauth:Sa,adminQuota:xa,adminSetQuota:B,adminSweep:U,adminBackup:$}}const ho=20;function ag(){const[i,r]=d.useState([]),[h,f]=d.useState(""),[y,A]=d.useState(""),[M,p]=d.useState(""),[_,R]=d.useState(""),[Q,b]=d.useState(""),[H,Z]=d.useState(0),[W,ot]=d.useState(0),[rt,bt]=d.useState({}),it=d.useRef(0),wt=d.useRef({status:"",executor:"",q:"",kind:""});wt.current={status:y,executor:M,q:Q,kind:_};const L=d.useRef(""),ut=d.useCallback(async(Y,z,vt)=>{const pt=Y!==void 0?Y:z;if(!pt){r([]),ot(0),bt({});return}L.current=pt;const ee=vt!==void 0?vt:it.current,fe=wt.current;try{const ne=await F(J.tasksPage(pt,ho,ee*ho,fe.status,fe.executor,fe.q,fe.kind));r(ne.tasks||[]),ot(ne.total??(ne.tasks||[]).length),bt(ne.status_counts||{}),f("")}catch(ne){f("加载失败:"+ne.message)}},[]),O=d.useCallback(Y=>{const z=Math.max(0,Y);it.current=z,Z(z)},[]),V=d.useCallback(Y=>{O(Y),ut(L.current||void 0,void 0,Y)},[ut,O]);d.useEffect(()=>{if(!L.current)return;const Y=setTimeout(()=>{ut(L.current,void 0,0)},300);return()=>clearTimeout(Y)},[y,M,Q,_]);const lt=d.useCallback(Y=>{A(Y),O(0)},[O]),St=d.useCallback(Y=>{p(Y),O(0)},[O]),Nt=d.useCallback(Y=>{R(Y),O(0)},[O]),mt=d.useCallback(Y=>{b(Y),O(0)},[O]),Tt=d.useCallback(async Y=>{O(0),await ut(Y,void 0,0)},[ut,O]),q=Math.max(1,Math.ceil(W/ho)),ht=W===0?"共 0 个任务":"共 "+W+" 个任务,第 "+(H+1)+" / "+q+" 页",_t=i,G=d.useCallback(async(Y,z)=>{const vt={...Y};z.length&&(vt.upload_id=await tg(z));const pt=await F(J.tasksCreate,"POST",vt);return"任务已建:"+se(pt.id)},[]);return{tasks:i,taskError:h,statusFilter:y,setStatusFilter:lt,executorFilter:M,setExecutorFilter:St,kindFilter:_,setKindFilter:Nt,searchText:Q,setSearchText:mt,filteredTasks:_t,taskCountText:ht,refreshTasks:ut,refreshTasksFirstPage:Tt,page:H,totalPages:q,taskTotal:W,taskStatusCounts:rt,goToPage:V,dispatch:G}}function ng(i,r){const[h,f]=d.useState(null),[y,A]=d.useState(""),[M,p]=d.useState(""),[_,R]=d.useState(""),[Q,b]=d.useState(""),[H,Z]=d.useState(void 0),[W,ot]=d.useState(!1),[rt,bt]=d.useState(""),[it,wt]=d.useState([]),[L,ut]=d.useState([]),[O,V]=d.useState([]),[lt,St]=d.useState(""),[Nt,mt]=d.useState([]),[Tt,q]=d.useState(""),[ht,_t]=d.useState(""),[G,Y]=d.useState([]),[z,vt]=d.useState(""),[pt,ee]=d.useState(""),[fe,ne]=d.useState(""),[g,w]=d.useState(""),[et,nt]=d.useState([]),[Mt,Ct]=d.useState([]),[kt,P]=d.useState(""),[ft,Ne]=d.useState([]),[Ja,Qe]=d.useStatLine truncated
`);yn=Bl.pop()||"";for(const yu of Bl){const gu=yu.split(`
`).filter(Ke=>Ke.indexOf("data: ")===0).map(Ke=>Ke.slice(6)).join(`
`);if(gu)try{const Ke=JSON.parse(gu);if(Ke.type==="snapshot")nt=Ke.entries||[],Object.keys(qt).forEach(ne=>delete qt[ne]),nt.forEach((ne,pe)=>{ne.id&&(qt[ne.id]=pe)}),hl=Ke.note||"",U([...nt]),L(hl),$l(q);else if(Ke.type==="entry"){const ne=Ke.entry;ne.id&&ne.id in qt?nt[qt[ne.id]]=ne:(ne.id&&(qt[ne.id]=nt.length),nt.push(ne)),U([...nt]),$l(q)}else Ke.type==="done"&&(D=!0,K=Ke.status,jt(K))}catch{}}if(D)break}}catch(J){if(J&&J.name==="AbortError")return;L("链路流中断:"+J.message);return}D&&r()},[fe,$l,r]),be=d.useCallback(async q=>{fe();try{const w=await $(W.task(q));f(w);let P="";w.result_text&&(P+=`=== result ===
`+w.result_text.slice(0,8e3)+`
`),w.error_text&&(P+=`=== error ===
`+w.error_text.slice(0,8e3)+`
`),P||(P="(暂无 result/error 输出)"),C(P),x(""),V(void 0),kl(q),ft(!1),yt(""),re(""),ge(""),vn(w.status);const nt=i(w.project_id),qt=nt==="maintainer"||nt==="owner",hl=nt==="contributor"||qt,D=su.includes(w.status),K=[];["failed","interrupted","merge-failed"].includes(w.status)&&qt||K.push("重试:不可用——仅 failed/interrupted/merge-failed 可重试"+(qt?"":",且仅 maintainer 可重试")),!D&&hl||K.push("取消:不可用——"+(D?"终态任务不可取消":"仅项目成员(contributor 及以上)可取消")),w.status==="completed"&&qt||K.push("合并:不可用——仅 completed 可合并"+(qt?"":",且仅 maintainer 可合并")),w.status==="merged"&&qt||K.push("提 PR:不可用——仅 merged 可提 PR"+(qt?"":",且仅 maintainer 可提 PR")),M(K.join(";")),S(""),Va(q,w.status),fl(q),ya(w),tl(q),te(q),dl(q),$t(q),Hl(q),Be(q),Nl(q)}catch(w){f(null),C("加载失败:"+w.message)}},[fe,Va,fl,ya,tl,te,dl,$t,Hl,Be,Nl,kl,i]),Il=d.useCallback(async()=>{const q=oe.current;if(q){try{const w=await $(W.task(q.id));f(w);let P="";w.result_text&&(P+=`=== result ===
`+w.result_text.slice(0,8e3)+`
`),w.error_text&&(P+=`=== error ===
`+w.error_text.slice(0,8e3)+`
`),C(P||"(暂无 result/error 输出)")}catch{}te(q.id),tl(q.id),Be(q.id),Nl(q.id),ya(q),fl(q.id),dl(q.id),$t(q.id),Hl(q.id),kl(q.id)}},[te,tl,Be,Nl,ya,fl,dl,$t,Hl,kl]),ga=d.useCallback(async q=>{const w=oe.current;if(!w)return;const P={cancel:"确定取消该任务吗?",merge:"确定把 completed 任务标为 merged 吗?",pr:"确定为该任务提 PR 吗?(会在仓库真实建 PR)"};if(!(P[q]&&!window.confirm(P[q])))try{const nt=await $(W.taskAction(w.id,q),"POST",{});S(q+" 已提交:"+JSON.stringify(nt).slice(0,300)),r(),await be(w.id)}catch(nt){S(q+" 失败:"+nt.message)}},[be,r]),fu=d.useCallback(async()=>{const q=oe.current;if(q)try{const w=await $(W.taskCost(q.id));x(JSON.stringify(w,null,2).slice(0,4e3))}catch(w){x("查成本失败:"+w.message)}},[]),du=d.useCallback(async q=>{const w=oe.current;if(w){if(!q.trim()){yt("分支消息必填");return}if(!su.includes(w.status)){yt("仅终态任务可分支");return}try{const P=await $(W.taskFork(w.id),"POST",{message:q.trim()});yt("分支任务已建:"+P.id.slice(0,8)),r()}catch(P){yt("分支失败:"+P.message)}}},[r]),Pl=d.useCallback(async q=>{const w=oe.current;if(w)try{await $(W.taskReviews(w.id),"POST",q),mt("评审已登记(requested)"),await Be(w.id)}catch(P){mt("登记评审需求失败:"+P.message)}},[Be]),Ce=d.useCallback(q=>{const w=oe.current;if(!w)return;const P=window.prompt("输入该条评审的 findings(将写回记录并标为 completed):");P!==null&&$(W.taskReviewFindings(w.id,q),"POST",{findings:P}).then(()=>void Be(w.id)).catch(nt=>mt("写 findings 失败:"+nt.message))},[Be]),ml=d.useCallback((q,w)=>{const P=oe.current;if(!P)return;const nt=w==="approved"?"approve":"deny";$(W.taskApprovalDecision(P.id,q,nt),"POST",{}).then(()=>{Nt("已记录"+(w==="approved"?"允许":"拒绝")+"(当前版本只留回执,不拦截实时行为)"),Nl(P.id)}).catch(qt=>Nt("决定失败:"+qt.message))},[Nl]),Ae=d.useCallback(async(q,w,P)=>{const nt=oe.current;if(nt){if(!w.trim()){re("目标任务 id 不能为空");return}if(!P.trim()){re("消息正文不能为空");return}try{const qt=await $(W.taskMessagesTo(w.trim()),"POST",{from_task_id:nt.id,body:P.trim(),mode:q});re("已发送("+qt.delivery+")"),await dl(nt.id)}catch(qt){re("发送失败:"+qt.message)}}},[dl]),mu=d.useCallback((q,w)=>{const P=oe.current;if(!P)return;if(!["completed","failed","interrupted"].includes(P.status)){ge("仅 completed/failed/interrupted 任务可操作线程(merged/merge-failed 不可改,服务端同样 409)");return}const nt={};if(q==="rename"){if(!w.trim()){ge("新线程名不能为空");return}nt.name=w.trim()}$(W.taskThreadAction(P.id,q),"POST",nt).then(qt=>{ge(q+" 已提交:"+JSON.stringify(qt).slice(0,300)),$t(P.id),Hl(P.id)}).catch(qt=>ge(q+" 失败:"+qt.message))},[$t,Hl]),hu=d.useCallback(async q=>{const w=oe.current;if(!w)return;const P=q.trim();if(!P){Ze("消息不能为空");return}try{su.includes(w.status)&&w.status!=="merged"?(await $(W.taskContinue(w.id),"POST",{message:P}),Ze("已重新排队,同一会话继续"),r(),await be(w.id)):["queued","running"].includes(w.status)?(await $(W.taskFollowups(w.id),"POST",{body:P,mode:"direct"}),Ze("已直接发送"),await te(w.id),await tl(w.id)):Ze("merged/merge-failed 任务不可继续,也不可发后续消息")}catch(nt){Ze("发送失败:"+nt.message)}},[be,r,te,tl]),vu=d.useCallback(async()=>{const q=oe.current;if(!q){ce("先选一个任务");return}try{const w=await $(W.taskTrace(q.id)),P=new Blob([JSON.stringify({task_id:q.id,status:w.status,note:w.note||"",count:(w.entries||[]).length,entries:w.entries||[]},null,2)],{type:"application/json"}),nt=document.createElement("a");nt.href=URL.createObjectURL(P),nt.download="trace-"+q.id.slice(0,8)+".json",document.body.appendChild(nt),nt.click(),nt.remove(),setTimeout(()=>URL.revokeObjectURL(nt.href),1e3),ce("已下载完整链路 "+(w.entries||[]).length+" 条")}catch(w){ce("下载失败:"+w.message)}},[]);return d.useEffect(()=>()=>fe(),[fe]),{currentTask:h,detailBody:y,actionMsg:A,actionReasons:_,costBox:Y,costSummary:B,forkVisible:et,setForkVisible:ft,forkMsg:F,coordChildren:_t,agents:Q,agentsNote:R,reviews:lt,reviewMsg:Et,reviewError:Rt,traceEntries:ut,traceNote:G,traceDone:it,traceKind:St,setTraceKind:ye,traceMsg:we,followups:g,approvals:st,approvalMsg:Mt,peerMessages:wt,peerMsg:ht,threadText:La,threadGateNote:Ve,threadMsg:Qt,threadList:Wl,chatMsg:va,chatHistory:Ul,showDetail:be,refreshDetailLists:Il,doAction:ga,taskCost:fu,forkTask:du,postReview:Pl,submitFindings:Ce,decideApproval:ml,postPeer:Ae,threadAction:mu,sendChatMessage:hu,downloadTrace:vu,refreshReviews:()=>void Be(),refreshApprovals:()=>void Nl()}}function ug(i){const[r,h]=d.useState([]),[f,y]=d.useState(""),[C,A]=d.useState(null),[S,_]=d.useState(""),[M,Y]=d.useState(null),[x,B]=d.useState(""),V=d.useCallback(async Q=>{const dt=Q!==void 0?Q:i();if(!dt){h([]);return}try{const R=await $(W.projectAutomations(dt));h(R.automations||[])}catLine truncated
(链路摘要已截断至 300 字,完整回复见右侧任务结果)`:f,label:"助手"}}if(i.kind==="exec"||i.kind==="file"||i.kind==="tool"||i.kind==="call"){const f=i.kind==="exec"?"命令":i.kind==="file"?"文件":i.kind==="tool"?"工具":"调用";return{id:h,role:"tool",kind:i.kind,text:yo(i),label:f}}return i.kind==="plan"?{id:h,role:"status",kind:i.kind,text:i.text||""}:i.kind==="reasoning"?{id:h,role:"status",kind:i.kind,text:"思考:"+(i.summary||"")}:i.kind==="agent-start"?{id:h,role:"status",kind:i.kind,text:"子代理开始:"+(i.name||"")+(i.prompt?"("+i.prompt+")":"")}:i.kind==="agent-done"?{id:h,role:"status",kind:i.kind,text:"子代理结束:"+(i.name||"")+" "+(i.status||"")}:{id:h,role:"status",kind:i.kind,text:yo(i)}}function pg(){const i=Lt(),r=i.currentTask,h=d.useMemo(()=>{if(!r)return[];let _;if(i.chatHistory){_=i.chatHistory.messages.map(et=>({id:et.id,role:et.role,text:et.text,label:et.label,kind:et.kind}));const B=new Set(_.map(et=>et.id)),V=i.traceEntries.filter(et=>et.id&&!B.has(et.id)&&!String(et.id).startsWith("fu-")).map((et,ft)=>Lh(et,ft));_=[..._,...V]}else{const B=r.prompt?[{id:"task-prompt",role:"user",text:r.prompt}]:[],V=i.traceEntries.filter(ft=>!String(ft.id||"").startsWith("fu-")).map((ft,F)=>Lh(ft,F)),et=i.followups.map((ft,F)=>({id:"followup-"+F,role:"user",text:String(ft.body||""),label:"后续消息"}));_=[...B,...V,...et]}const M=i.traceKind,Y=M?_.filter(B=>B.role==="user"||!B.kind||B.kind===M):_,x=i.traceDone?[{id:"trace-done",role:"status",text:"链路已结束("+i.traceDone+")"}]:[];return[...Y,...x]},[r,i.chatHistory,i.traceEntries,i.followups,i.traceKind,i.traceDone]);if(!r)return c.jsx("div",{className:"muted",children:"点任务行查看详情后,这里显示继续会话"});const f=su.includes(r.status),y=["queued","running"].includes(r.status),C=f||y,A=f?"终态任务:发送走继续接口(同一会话再跑一轮)":y?"运行中/排队中:发送走后续消息直接发送(跑完当前轮后优先执行)":"merged/merge-failed 任务不可继续,也不可发后续消息",S=i.chatHistory?i.chatHistory.legacy_continue_count??null:null;return c.jsxs("div",{className:"chat-column",children:[c.jsx("div",{className:"muted",children:"实时对话"}),c.jsxs(At,{children:[c.jsxs("select",{"aria-label":"链路类型",value:i.traceKind,onChange:_=>i.setTraceKind(_.target.value),children:[c.jsx("option",{value:"",children:"链路类型:全部"}),["exec","file","call","tool","message","reasoning","plan","agent-start","agent-done"].map(_=>c.jsx("option",{value:_,children:_==="exec"?"命令":_==="file"?"文件":_==="call"?"调用":_==="tool"?"工具":_==="message"?"消息":_==="reasoning"?"思考":_==="plan"?"计划":"子代理"},_))]}),c.jsx("button",{onClick:i.downloadTrace,children:"下载完整链路"}),c.jsx("span",{className:"muted",children:i.traceMsg})]}),i.traceNote?c.jsx("div",{children:c.jsx("span",{className:"err",children:i.traceNote})}):null,S!==null&&S>0?c.jsxs("div",{className:"muted",children:["此前有 ",S," 条继续消息发生在持久化之前,未入库,上面历史不完整;部署后新发的继续消息会正常显示。"]}):null,i.chatHistory&&i.chatHistory.trace_note?c.jsx("div",{className:"muted",children:i.chatHistory.trace_note}):null,c.jsx(yg,{messages:h,empty:"(暂无会话消息)"}),c.jsx(gg,{disabled:!C,placeholder:"消息",onSend:_=>void i.sendChatMessage(_)}),c.jsxs("div",{className:"muted",children:[A,"。",i.chatMsg]}),c.jsx("div",{className:"muted",children:"对话时间线:首条是本任务 prompt(用户);助手回复、命令/文件/工具/调用、计划/思考/子代理开始结束都在同一列里,不再另起一份链路名单。流式进行中时新条目实时追加;你在底部时自动跟随,往上翻时暂停跟随,点“回到最新”恢复。"})]})}function Sg(){return Lt().currentTask?c.jsx("div",{className:"conversation",children:c.jsx(pg,{})}):c.jsx("div",{className:"conversation",children:c.jsx("div",{className:"muted",children:"在任务列表里点一条任务,对话和详情就切到该任务;也可以在上面派发一个新任务。"})})}function xg(){const i=Lt(),r=i.currentTask,[h,f]=d.useState("");if(!r)return null;const y=su.includes(r.status),C=["failed","interrupted","merge-failed"].includes(r.status);return c.jsxs(c.Fragment,{children:[c.jsxs(At,{children:[c.jsx("button",{disabled:!C,title:C?"":"仅 failed/interrupted/merge-failed 可重试",onClick:()=>i.doAction("retry"),children:"重试"}),c.jsx("button",{className:"danger",disabled:y,title:y?"终态任务不可取消":"",onClick:()=>i.doAction("cancel"),children:"取消"}),c.jsx("button",{disabled:r.status!=="completed",title:r.status!=="completed"?"仅 completed 可合并":"",onClick:()=>i.doAction("merge"),children:"合并(completed → merged)"}),c.jsx("button",{disabled:r.status!=="merged",title:r.status!=="merged"?"仅 merged 可提 PR":"",onClick:()=>i.doAction("pr"),childrLine truncated
`);Fa=ea.pop()||"";for(const El of ea){const he=El.split(`
`).filter($t=>$t.indexOf("data: ")===0).map($t=>$t.slice(6)).join(`
`);if(he)try{const $t=JSON.parse(he);if($t.type==="snapshot")N=$t.entries||[],Object.keys(X).forEach(xe=>delete X[xe]),N.forEach((xe,fi)=>{xe.id&&(X[xe.id]=fi)}),tt=$t.note||"",Y([...N]),vt(tt),Fl(B);else if($t.type==="entry"){const xe=$t.entry;xe.id&&xe.id in X?N[X[xe.id]]=xe:(xe.id&&(X[xe.id]=N.length),N.push(xe)),Y([...N]),Fl(B)}else $t.type==="done"&&(Ut=!0,yt=$t.status,ee(yt))}catch{}}if(Ut)break}}catch(Wt){if(Wt&&Wt.name==="AbortError")return;vt("链路流中断:"+Wt.message);return}Ut&&r()},[il,Fl,r]),Il=d.useCallback(async B=>{il();try{const U=await F(J.task(B));f(U);let $="";U.result_text&&($+=`=== result ===
`+U.result_text.slice(0,8e3)+`
`),U.error_text&&($+=`=== error ===
`+U.error_text.slice(0,8e3)+`
`),$||($="(暂无 result/error 输出)"),A($),b(""),Z(void 0),Ul(B),ot(!1),bt(""),Qe(""),re(""),Wl(U.status);const N=i(U.project_id),X=N==="maintainer"||N==="owner",tt=N==="contributor"||X,Ut=ou.includes(U.status),yt=[];["failed","interrupted","merge-failed"].includes(U.status)&&X||yt.push("重试:不可用——仅 failed/interrupted/merge-failed 可重试"+(X?"":",且仅 maintainer 可重试")),!Ut&&tt||yt.push("取消:不可用——"+(Ut?"终态任务不可取消":"仅项目成员(contributor 及以上)可取消")),U.status==="completed"&&X||yt.push("合并:不可用——仅 completed 可合并"+(X?"":",且仅 maintainer 可合并")),U.status==="merged"&&X||yt.push("提 PR:不可用——仅 merged 可提 PR"+(X?"":",且仅 maintainer 可提 PR")),R(yt.join(";")),p(""),$l(B,U.status),kl(B),ue(U),jl(U),cl(B),wl(B),Tl(B),_l(B),Se(B),Xt(B),bl(B)}catch(U){f(null),A("加载失败:"+U.message)}},[il,$l,kl,ue,jl,cl,wl,Tl,_l,Se,Xt,bl,Ul,i]),du=d.useCallback(async()=>{const B=me.current;if(B){try{const U=await F(J.task(B.id));f(U);let $="";U.result_text&&($+=`=== result ===
`+U.result_text.slice(0,8e3)+`
`),U.error_text&&($+=`=== error ===
`+U.error_text.slice(0,8e3)+`
`),A($||"(暂无 result/error 输出)")}catch{}wl(B.id),cl(B.id),Xt(B.id),bl(B.id),ue(B),jl(B),kl(B.id),Tl(B.id),_l(B.id),Se(B.id),Ul(B.id)}},[wl,cl,Xt,bl,ue,jl,kl,Tl,_l,Se,Ul]),Pl=d.useCallback(async B=>{const U=me.current;if(!U)return;const $={cancel:"确定取消该任务吗?",merge:"确定把 completed 任务标为 merged 吗?",pr:"确定为该任务提 PR 吗?(会在仓库真实建 PR)"};if(!($[B]&&!window.confirm($[B])))try{const N=await F(J.taskAction(U.id,B),"POST",{});p(B+" 已提交:"+JSON.stringify(N).slice(0,300)),r(),await Il(U.id)}catch(N){p(B+" 失败:"+N.message)}},[Il,r]),Ce=d.useCallback(async()=>{const B=me.current;if(B)try{const U=await F(J.taskCost(B.id));b(JSON.stringify(U,null,2).slice(0,4e3))}catch(U){b("查成本失败:"+U.message)}},[]),sl=d.useCallback(async B=>{const U=me.current;if(U){if(!B.trim()){bt("分支消息必填");return}if(!ou.includes(U.status)){bt("仅终态任务可分支");return}try{const $=await F(J.taskFork(U.id),"POST",{message:B.trim()});bt("分支任务已建:"+$.id.slice(0,8)),r()}catch($){bt("分支失败:"+$.message)}}},[r]),Ae=d.useCallback(async B=>{const U=me.current;if(U)try{await F(J.taskReviews(U.id),"POST",B),q("评审已登记(requested)"),await Xt(U.id)}catch($){q("登记评审需求失败:"+$.message)}},[Xt]),mu=d.useCallback(B=>{const U=me.current;if(!U)return;const $=window.prompt("输入该条评审的 findings(将写回记录并标为 completed):");$!==null&&F(J.taskReviewFindings(U.id,B),"POST",{findings:$}).then(()=>void Xt(U.id)).catch(N=>q("写 findings 失败:"+N.message))},[Xt]),hu=d.useCallback((B,U)=>{const $=me.current;if(!$)return;const N=U==="approved"?"approve":"deny";F(J.taskApprovalDecision($.id,B,N),"POST",{}).then(()=>{P("已记录"+(U==="approved"?"允许":"拒绝")+"(当前版本只留回执,不拦截实时行为)"),bl($.id)}).catch(X=>P("决定失败:"+X.message))},[bl]),vu=d.useCallback(async(B,U,$)=>{const N=me.current;if(N){if(!U.trim()){Qe("目标任务 id 不能为空");return}if(!$.trim()){Qe("消息正文不能为空");return}try{const X=await F(J.taskMessagesTo(U.trim()),"POST",{from_task_id:N.id,body:$.trim(),mode:B});Qe("已发送("+X.delivery+")"),await Tl(N.id)}catch(X){Qe("发送失败:"+X.message)}}},[Tl]),pa=d.useCallback((B,U)=>{const $=me.current;if(!$)return;if(!["completed","failed","interrupted"].includes($.status)){re("仅 completed/failed/interrupted 任务可操作线程(merged/merge-failed 不可改,服务端同样 409)");return}const N={};if(B==="rename"){if(!U.trim()){re("新线程名不能为空");return}N.name=U.trim()}F(J.taskThreadAction($.id,B),"POST",N).then(X=>{re(B+" 已提交:"+JSON.stringify(X).slice(0,300)),_l($.id),Se($.id)}).catch(X=>re(B+" 失败:"+X.message))},[_l,Se]),Sa=d.useCallback(async B=>{const U=me.current;if(!U)return;const $=B.trim();if(!$){Fe("消息不能为空");return}try{ou.includes(U.status)&&U.status!=="merged"?(await F(J.taskContinue(U.id),"POST",{message:$}),Fe("已重新排队,同一会话继续"),r(),await Il(U.id)):["queued","running"].includes(U.status)?(await F(J.taskFollowups(U.id),"POST",{body:$,mode:"direct"}),Fe("已直接发送"),await wl(U.id),await cl(U.id)):Fe("merged/merge-failed 任务不可继续,也不可发后续消息")}catch(N){Fe("发送失败:"+N.message)}},[Il,r,wl,cl]),xa=d.useCallback(async()=>{const B=me.current;if(!B){w("先选一个任务");return}try{const U=await F(J.taskTrace(B.id)),$=new Blob([JSON.stringify({task_id:B.id,status:U.status,note:U.note||"",count:(U.entries||[]).length,entries:U.entries||[]},null,2)],{type:"application/json"}),N=document.createElement("a");N.href=URL.createObjectURL($),N.download="trace-"+B.id.slice(0,8)+".json",document.body.appendChild(N),N.click(),N.remove(),setTimeout(()=>URL.revokeObjectURL(N.href),1e3),w("已下载完整链路 "+(U.entries||[]).length+" 条")}catch(U){w("下载失败:"+U.message)}},[]);return d.useEffect(()=>()=>il(),[il]),{currentTask:h,detailBody:y,actionMsg:M,actionReasons:_,costBox:Q,costSummary:H,forkVisible:W,setForkVisible:ot,forkMsg:rt,coordChildren:it,sideThreads:L,agents:O,agentsNote:lt,reviews:Nt,reviewMsg:Tt,reviewError:ht,traceEntries:G,traceNote:z,traceDone:pt,traceKind:fe,setTraceKind:ne,traceMsg:g,followups:et,approvals:Mt,approvalMsg:kt,peerMessages:ft,peerMsg:Ja,threadText:Xe,threadGateNote:Gt,threadMsg:Jl,threadList:ya,chatMsg:Rl,chatHistory:ga,showDetail:Il,refreshDetailLists:du,doAction:Pl,taskCost:Ce,forkTask:sl,postReview:Ae,submitFindings:mu,decideApproval:hu,postPeer:vu,threadAction:pa,sendChatMessage:Sa,downloadTrace:xa,refreshReviews:()=>void Xt(),refreshApprovals:()=>void bl()}}function ug(i){const[r,h]=d.useState([]),[f,y]=d.useState(""),[A,M]=d.useState(null),[p,_]=d.useState(""),[R,Q]=d.useState(null),[b,H]=d.useState(""),Z=d.useCallback(async L=>{const ut=L!==void 0?L:i();if(!ut){h([]);return}try{const O=await F(J.projectAutomations(ut));h(O.automations||[])}catch(O){y("自动化Line truncated
(链路摘要已截断至 300 字,完整回复见右侧任务结果)`:f,label:"助手"}}if(i.kind==="exec"||i.kind==="file"||i.kind==="tool"||i.kind==="call"){const f=i.kind==="exec"?"命令":i.kind==="file"?"文件":i.kind==="tool"?"工具":"调用";return{id:h,role:"tool",kind:i.kind,text:go(i),label:f}}return i.kind==="plan"?{id:h,role:"status",kind:i.kind,text:i.text||""}:i.kind==="reasoning"?{id:h,role:"status",kind:i.kind,text:"思考:"+(i.summary||"")}:i.kind==="agent-start"?{id:h,role:"status",kind:i.kind,text:"子代理开始:"+(i.name||"")+(i.prompt?"("+i.prompt+")":"")}:i.kind==="agent-done"?{id:h,role:"status",kind:i.kind,text:"子代理结束:"+(i.name||"")+" "+(i.status||"")}:{id:h,role:"status",kind:i.kind,text:go(i)}}function pg(){const i=Yt(),r=i.currentTask,h=d.useMemo(()=>{if(!r)return[];let _;if(i.chatHistory){_=i.chatHistory.messages.map(W=>({id:W.id,role:W.role,text:W.text,label:W.label,kind:W.kind}));const H=new Set(_.map(W=>W.id)),Z=i.traceEntries.filter(W=>W.id&&!H.has(W.id)&&!String(W.id).startsWith("fu-")).map((W,ot)=>Lh(W,ot));_=[..._,...Z]}else{const H=r.prompt?[{id:"task-prompt",role:"user",text:r.prompt}]:[],Z=i.traceEntries.filter(ot=>!String(ot.id||"").startsWith("fu-")).map((ot,rt)=>Lh(ot,rt)),W=i.followups.map((ot,rt)=>({id:"followup-"+rt,role:"user",text:String(ot.body||""),label:"后续消息"}));_=[...H,...Z,...W]}const R=i.traceKind,Q=R?_.filter(H=>H.role==="user"||!H.kind||H.kind===R):_,b=i.traceDone?[{id:"trace-done",role:"status",text:"链路已结束("+i.traceDone+")"}]:[];return[...Q,...b]},[r,i.chatHistory,i.traceEntries,i.followups,i.traceKind,i.traceDone]);if(!r)return c.jsx("div",{className:"muted",children:"点任务行查看详情后,这里显示继续会话"});const f=ou.includes(r.status),y=["queued","running"].includes(r.status),A=f||y,M=f?"终态任务:发送走继续接口(同一会话再跑一轮)":y?"运行中/排队中:发送走后续消息直接发送(跑完当前轮后优先执行)":"merged/merge-failed 任务不可继续,也不可发后续消息",p=i.chatHistory?i.chatHistory.legacy_continue_count??null:null;return c.jsxs("div",{className:"chat-column",children:[c.jsx("div",{className:"muted",children:"实时对话"}),c.jsxs(At,{children:[c.jsxs("select",{"aria-label":"链路类型",value:i.traceKind,onChange:_=>i.setTraceKind(_.target.value),children:[c.jsx("option",{value:"",children:"链路类型:全部"}),["exec","file","call","tool","message","reasoning","plan","agent-start","agent-done"].map(_=>c.jsx("option",{value:_,children:_==="exec"?"命令":_==="file"?"文件":_==="call"?"调用":_==="tool"?"工具":_==="message"?"消息":_==="reasoning"?"思考":_==="plan"?"计划":"子代理"},_))]}),c.jsx("button",{onClick:i.downloadTrace,children:"下载完整链路"}),c.jsx("span",{className:"muted",children:i.traceMsg})]}),i.traceNote?c.jsx("div",{children:c.jsx("span",{className:"err",children:i.traceNote})}):null,p!==null&&p>0?c.jsxs("div",{className:"muted",children:["此前有 ",p," 条继续消息发生在持久化之前,未入库,上面历史不完整;部署后新发的继续消息会正常显示。"]}):null,i.chatHistory&&i.chatHistory.trace_note?c.jsx("div",{className:"muted",children:i.chatHistory.trace_note}):null,c.jsx(yg,{messages:h,empty:"(暂无会话消息)"}),c.jsx(gg,{disabled:!A,placeholder:"消息",onSend:_=>void i.sendChatMessage(_)}),c.jsxs("div",{className:"muted",children:[M,"。",i.chatMsg]}),c.jsx("div",{className:"muted",children:"对话时间线:首条是本任务 prompt(用户);助手回复、命令/文件/工具/调用、计划/思考/子代理开始结束都在同一列里,不再另起一份链路名单。流式进行中时新条目实时追加;你在底部时自动跟随,往上翻时暂停跟随,点“回到最新”恢复。"})]})}function Sg(){return Yt().currentTask?c.jsx("div",{className:"conversation",children:c.jsx(pg,{})}):c.jsx("div",{className:"conversation",children:c.jsx("div",{className:"muted",children:"在任务列表里点一条任务,对话和详情就切到该任务;也可以在上面派发一个新任务。"})})}function xg(){const i=Yt(),r=i.currentTask,[h,f]=d.useState("");if(!r)return null;const y=ou.includes(r.status),A=["failed","interrupted","merge-failed"].includes(r.status),M=r.kind==="side";return c.jsxs(c.Fragment,{children:[c.jsxs(At,{children:[c.jsx("button",{disabled:!A,title:A?"":"仅 failed/interrupted/merge-failed 可重试",onClick:()=>i.doAction("retry"),children:"重试"}),c.jsx("button",{className:"danger",disabled:y,title:y?"终态任务不可取消":"",onClick:()=>i.doAction("cancel"),children:"取消"}),M?null:c.jsxs(c.Fragment,{children:[c.jsx("button",{disabled:r.status!=="completed",title:r.status!=="completed"?"仅 completed 可合并":"",onClick:()=>i.doAction("merge"),children:"合并(completed → merged)"}),c.jsx("button",{disabled:r.status!=="merged",title:r.status!=="merged"?"仅 merged 可提 Line truncated
`);for(;ut>=0;){const U=mt.slice(0,ut);mt=mt.slice(ut+2);for(const G of U.split(`
`))if(G.startsWith("data: "))try{const L=JSON.parse(G.slice(6));L.waiting?S(L.task_status==="interrupted"?"任务已中断,等待续跑…":L.task_status==="paused"?"任务已暂停…":"等待会话…"):L.image&&(f("data:image/jpeg;base64,"+L.image),S("直播中"));const it={};L.holder&&(it.holder=L.holder),L.url&&(it.url=L.url),L.title!==void 0&&(it.title=L.title),Object.keys(it).length>0&&C(jt=>({...jt||{},...it}))}catch{}ut=mt.indexOf(`
`);for(;ht>=0;){const _t=mt.slice(0,ht);mt=mt.slice(ht+2);for(const G of _t.split(`
`))if(G.startsWith("data: "))try{const Y=JSON.parse(G.slice(6));Y.waiting?p(Y.task_status==="interrupted"?"任务已中断,等待续跑…":Y.task_status==="paused"?"任务已暂停…":"等待会话…"):Y.image&&(f("data:image/jpeg;base64,"+Y.image),p("直播中"));const z={};Y.holder&&(z.holder=Y.holder),Y.url&&(z.url=Y.url),Y.title!==void 0&&(z.title=Y.title),Object.keys(z).length>0&&A(vt=>({...vt||{},...z}))}catch{}ht=mt.indexOf(`
`)}}S("流已断开")}catch(lt){if(lt.name==="AbortError")return;M(lt instanceof Error?lt.message:String(lt)),S("未开始")}finally{x(!1)}},[r]),F=d.useCallback(()=>{var R;(R=B.current)==null||R.abort(),x(!1),S("已停止")},[]),yt=d.useCallback(async R=>{if(r)try{const X=await $(W.taskBrowserControl(r.id),"POST",{holder:R});C(X),M("")}catch(X){M(X instanceof Error?X.message:String(X))}},[r]),_t=d.useCallback(async R=>{if(!r||(y==null?void 0:y.holder)!=="user"||!V.current)return;const X=V.current.getBoundingClientRect(),lt=Math.round((R.clientX-X.left)/X.width*1280),pt=Math.round((R.clientY-X.top)/X.height*800);try{await $(W.taskBrowserInput(r.id),"POST",{type:"mouse_click",x:lt,y:pt})}catch(Et){M(Et instanceof Error?Et.message:String(Et))}},[r,y]),kt=d.useCallback(async R=>{if(!(!r||(y==null?void 0:y.holder)!=="user")){R.preventDefault();try{R.key.length===1?await $(W.taskBrowserInput(r.id),"POST",{type:"type",text:R.key}):await $(W.taskBrowserInput(r.id),"POST",{type:"key",key:R.key})}catch(X){M(X instanceof Error?X.message:String(X))}}},[r,y]);if(!r)return c.jsx("div",{className:"muted",children:"点任务行查看详情"});const Q=(y==null?void 0:y.holder)||"—",dt=y!==null;return c.jsxs("div",{children:[c.jsxs("div",{style:{display:"flex",gap:8,flexWrap:"wrap",alignItems:"center"},children:[c.jsxs("span",{children:["状态 ",c.jsx("b",{children:A})]}),c.jsxs("span",{className:"muted",children:["会话 ",dt?"活跃":"未激活(轮次结束即消失)"]}),c.jsxs("span",{className:"muted",children:["控制权 ",Q]}),y!=null&&y.url?c.jsx("span",{className:"muted",title:y.url,children:y.url.slice(0,80)}):null]}),c.jsxs("div",{style:{display:"flex",gap:8,flexWrap:"wrap",marginTop:8},children:[Y?c.jsx("button",{type:"button",onClick:F,children:"停止观看"}):c.jsx("button",{type:"button",onClick:()=>void ft(),children:"开始观看"}),c.jsx("button",{type:"button",onClick:()=>void et(),children:"刷新状态"}),Q!=="user"?c.jsx("button",{type:"button",onClick:()=>void yt("user"),disabled:!dt,children:"接管"}):c.jsx("button",{type:"button",onClick:()=>void yt("agent"),children:"交还给 Agent"})]}),_?c.jsx("div",{className:"muted",style:{marginTop:8},children:_}):null,Q==="user"?c.jsx("div",{className:"muted",style:{marginTop:8},children:"你已接管:点画面即点击,键盘输入直达页面。交还后 Agent 续跑前会先快照确认你的改动。"}):null,c.jsx("div",{style:{marginTop:8},children:h?c.jsx("img",{ref:V,src:h,alt:"浏览器实时画面",tabIndex:0,onClick:R=>void _t(R),onKeyDown:R=>void kt(R),style:{width:"100%",maxWidth:1280,cursor:Q==="user"?"crosshair":"default",border:"1px solid var(--border, #ccc)"}}):c.jsx("div",{className:"muted",children:dt?"点“开始观看”接入画面流(接管后点画面即点击)":"浏览器会话未激活:任务本轮未开浏览器,或轮次已结束。"})})]})}function bg(){const i=Lt(),[r,h]=d.useState("uncommittedChanges"),[f,y]=d.useState(""),[C,A]=d.useState(""),[S,_]=d.useState("");return c.jsxs(c.Fragment,{children:[c.jsx("h3",{children:"评审队列"}),c.jsxs("select",{"aria-label":"评审目标",value:r,onChange:M=>h(M.target.value),children:[c.jsx("option",{value:"uncommittedChanges",children:"未提交改动(工作区)"}),c.jsx("option",{value:"baseBranch",children:"与分支的差异"}),c.jsx("option",{value:"commit",children:"指定提交"}),c.jsx("option",{value:"custom",children:"自定义评审指令"})]}),c.jsx("input",{className:"stacked",placeholder:"与分支的差异用:分支名(如 main)",style:{width:"100%"},value:f,onChange:M=>y(M.target.value)}),c.jsx("input",{className:"stacked",placeholder:"指定提交用:提交 sha",style:{width:"100%"},value:C,onChange:M=>A(M.target.value)}),c.jsx("textarea",{placeholder:"自定义评审指令用:评审指令正文",value:S,onChange:M=>_(M.target.value)}),c.jsxs(At,{children:[c.jsx("button",{className:"primary",onClick:()=>void i.postReview({target_type:r,branch:f.trim()||void 0,sha:C.trim()||void 0,instructions:S.trim()||void 0}),children:"登记评审需求"}),c.jsx("button",{onClick:i.refreshReviews,children:"刷新队列"}),c.jsx("span",{className:"muted",children:i.reviewMsg})]}),c.jsx("div",{className:"muted",children:"本基线只登记评审需求(requested),不自动跑评审;评审是否执行、结果如何,以评审队列里的记录为准。"}),i.reviewError?c.jsx("div",{className:"muted",children:i.reviewError}):i.reviews.length===0?c.jsx("div",{className:"muted",children:"点任务行查看详情后,点“刷新队列”查看评审记录"}):c.jsx(ou,{children:i.reviews.map(M=>{let Y=M.target_type||"";return M.branch&&(Y+=":"+M.branch),M.sha&&(Y+=":"+String(M.sha).slice(0,12)),M.instructions&&(Y+=":"+String(M.instructions).slice(0,80)),c.jsxs("li",{children:[c.jsxs("span",{className:"row-main",children:[c.jsx("b",{children:"评审"})," ",xe(M.id)," | ",Y," | ",c.jsx("span",{className:"st-"+(M.status||""),children:M.sLine truncated
`)}}p("流已断开")}catch(lt){if(lt.name==="AbortError")return;R(lt instanceof Error?lt.message:String(lt)),p("未开始")}finally{b(!1)}},[r]),rt=d.useCallback(()=>{var O;(O=H.current)==null||O.abort(),b(!1),p("已停止")},[]),bt=d.useCallback(async O=>{if(r)try{const V=await F(J.taskBrowserControl(r.id),"POST",{holder:O});A(V),R("")}catch(V){R(V instanceof Error?V.message:String(V))}},[r]),it=d.useCallback(async O=>{if(!r||(y==null?void 0:y.holder)!=="user"||!Z.current)return;const V=Z.current.getBoundingClientRect(),lt=Math.round((O.clientX-V.left)/V.width*1280),St=Math.round((O.clientY-V.top)/V.height*800);try{await F(J.taskBrowserInput(r.id),"POST",{type:"mouse_click",x:lt,y:St})}catch(Nt){R(Nt instanceof Error?Nt.message:String(Nt))}},[r,y]),wt=d.useCallback(async O=>{if(!(!r||(y==null?void 0:y.holder)!=="user")){O.preventDefault();try{O.key.length===1?await F(J.taskBrowserInput(r.id),"POST",{type:"type",text:O.key}):await F(J.taskBrowserInput(r.id),"POST",{type:"key",key:O.key})}catch(V){R(V instanceof Error?V.message:String(V))}}},[r,y]);if(!r)return c.jsx("div",{className:"muted",children:"点任务行查看详情"});const L=(y==null?void 0:y.holder)||"—",ut=y!==null;return c.jsxs("div",{children:[c.jsxs("div",{style:{display:"flex",gap:8,flexWrap:"wrap",alignItems:"center"},children:[c.jsxs("span",{children:["状态 ",c.jsx("b",{children:M})]}),c.jsxs("span",{className:"muted",children:["会话 ",ut?"活跃":"未激活(轮次结束即消失)"]}),c.jsxs("span",{className:"muted",children:["控制权 ",L]}),y!=null&&y.url?c.jsx("span",{className:"muted",title:y.url,children:y.url.slice(0,80)}):null]}),c.jsxs("div",{style:{display:"flex",gap:8,flexWrap:"wrap",marginTop:8},children:[Q?c.jsx("button",{type:"button",onClick:rt,children:"停止观看"}):c.jsx("button",{type:"button",onClick:()=>void ot(),children:"开始观看"}),c.jsx("button",{type:"button",onClick:()=>void W(),children:"刷新状态"}),L!=="user"?c.jsx("button",{type:"button",onClick:()=>void bt("user"),disabled:!ut,children:"接管"}):c.jsx("button",{type:"button",onClick:()=>void bt("agent"),children:"交还给 Agent"})]}),_?c.jsx("div",{className:"muted",style:{marginTop:8},children:_}):null,L==="user"?c.jsx("div",{className:"muted",style:{marginTop:8},children:"你已接管:点画面即点击,键盘输入直达页面。交还后 Agent 续跑前会先快照确认你的改动。"}):null,c.jsx("div",{style:{marginTop:8},children:h?c.jsx("img",{ref:Z,src:h,alt:"浏览器实时画面",tabIndex:0,onClick:O=>void it(O),onKeyDown:O=>void wt(O),style:{width:"100%",maxWidth:1280,cursor:L==="user"?"crosshair":"default",border:"1px solid var(--border, #ccc)"}}):c.jsx("div",{className:"muted",children:ut?"点“开始观看”接入画面流(接管后点画面即点击)":"浏览器会话未激活:任务本轮未开浏览器,或轮次已结束。"})})]})}function bg(){const i=Yt(),[r,h]=d.useState("uncommittedChanges"),[f,y]=d.useState(""),[A,M]=d.useState(""),[p,_]=d.useState("");return c.jsxs(c.Fragment,{children:[c.jsx("h3",{children:"评审队列"}),c.jsxs("select",{"aria-label":"评审目标",value:r,onChange:R=>h(R.target.value),children:[c.jsx("option",{value:"uncommittedChanges",children:"未提交改动(工作区)"}),c.jsx("option",{value:"baseBranch",children:"与分支的差异"}),c.jsx("option",{value:"commit",children:"指定提交"}),c.jsx("option",{value:"custom",children:"自定义评审指令"})]}),c.jsx("input",{className:"stacked",placeholder:"与分支的差异用:分支名(如 main)",style:{width:"100%"},value:f,onChange:R=>y(R.target.value)}),c.jsx("input",{className:"stacked",placeholder:"指定提交用:提交 sha",style:{width:"100%"},value:A,onChange:R=>M(R.target.value)}),c.jsx("textarea",{placeholder:"自定义评审指令用:评审指令正文",value:p,onChange:R=>_(R.target.value)}),c.jsxs(At,{children:[c.jsx("button",{className:"primary",onClick:()=>void i.postReview({target_type:r,branch:f.trim()||void 0,sha:A.trim()||void 0,instructions:p.trim()||void 0}),children:"登记评审需求"}),c.jsx("button",{onClick:i.refreshReviews,children:"刷新队列"}),c.jsx("span",{className:"muted",children:i.reviewMsg})]}),c.jsx("div",{className:"muted",children:"本基线只登记评审需求(requested),不自动跑评审;评审是否执行、结果如何,以评审队列里的记录为准。"}),i.reviewError?c.jsx("div",{className:"muted",children:i.reviewError}):i.reviews.length===0?c.jsx("div",{className:"muted",children:"点任务行查看详情后,点“刷新队列”查看评审记录"}):c.jsx(Sn,{children:i.reviews.map(R=>{let Q=R.target_type||"";return R.branch&&(Q+=":"+R.branch),R.sha&&(Q+=":"+String(R.sha).slice(0,12)),R.instructions&&(Q+=":"+String(R.instructions).slice(0,80)),c.jsxs("li",{children:[c.jsxs("span",{className:"row-main",children:[c.jsx("b",{children:"评审"})," ",se(R.id)," | ",Q," | ",c.jsx("span",{className:"st-"+(R.status||""),children:R.Line truncated
<style rel="stylesheet" crossorigin>:root{--text:#1f2328;--muted:#656d76;--subtle:#6e7781;--line:#d0d7de;--line-muted:#d8dee4;--paper:#ffffff;--card:#ffffff;--subtle-bg:#f6f8fa;--inset-bg:#f6f8fa;--accent:#0969da;--accent-emphasis:#0969da;--green:#2da44e;--green-hover:#2c974b;--success:#1a7f37;--danger:#d1242f;--danger-bg:#ffebe9;--attention:#9a6700;--done:#8250df;--selected-bg:#ddf4ff;--radius:6px;--shadow:0 1px 0 rgba(27,31,36,.04);--sans:-apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif;--mono:ui-monospace,SFMono-Regular,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;--ctl:32px}*{box-sizing:border-box}html,body,#root{height:100%}body{font-family:var(--sans);margin:0;color:var(--text);background:#fff;line-height:1.5;font-size:14px}a{color:var(--accent);text-underline-offset:3px}:focus-visible{outline:2px solid var(--accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important}}.muted{color:var(--muted);font-size:12px;line-height:1.5}.err{color:var(--danger)}.ok{color:var(--success)}button{cursor:pointer;font-weight:500;border-radius:var(--radius);border:1px solid var(--line);background:#fff;color:var(--text);box-shadow:var(--shadow)}button:hover{background:var(--subtle-bg)}button:disabled{opacity:.45;cursor:not-allowed;box-shadow:none}button.primary,button.seal{background:var(--green);border-color:#1b1f2426;color:#fff}button.primary:hover,button.seal:hover{background:var(--green-hover)}button.danger{color:var(--danger);border-color:var(--line);background:#fff}button.danger:hover{background:var(--danger-bg);color:var(--danger)}.icon-btn{width:32px;height:32px;padding:0;display:grid;place-items:center;border:1px solid var(--line);border-radius:var(--radius);background:#fff;color:var(--text);font-size:16px;line-height:1}.icon-btn:hover{background:var(--subtle-bg)}.dash-topbar{position:sticky;top:0;z-index:10;background:#24292f;color:#fff;border-bottom:1px solid rgba(255,255,255,.15);padding:10px 20px 8px}.dash-topbar .muted{color:#ffffffbf}.dash-topbar-row{display:flex;gap:14px;align-items:center;flex-wrap:wrap}.dash-brand{font-weight:600;font-size:16px;color:#fff}.dash-status{font-size:13px;font-weight:600;color:#ffffffbf}.dash-status.ok{color:#2da44e}.dash-stamp{margin-left:auto}.dash-nav{display:flex;gap:4px;flex-wrap:wrap;margin-top:8px}.dash-nav a{color:#fff;text-decoration:none;font-size:13px;font-weight:600;padding:6px 12px;border-radius:var(--radius)}.dash-nav a:hover{background:#ffffff1f;text-decoration:none}.tab-nav button{background:transparent;border:0;box-shadow:none;color:#fff;font-size:13px;font-weight:600;padding:6px 12px;border-radius:var(--radius)}.tab-nav button:hover{background:#ffffff1f}.tab-nav button.active{background:#ffffff2e}.dash-page-tabs{display:flex}.dash-page-tabs>.tab-content{grid-column:1 / -1;min-width:0;display:flex;flex-direction:column;gap:20px}.tab-workbench{display:grid;grid-template-columns:320px minmax(0,1fr);gap:20px;align-items:start}.tab-workbench>*{min-width:0}@media(max-width:899px){.tab-workbench{grid-template-columns:1fr}.tab-workbench #sec-dispatch{order:1}.tab-workbench #sec-tasks{order:2}.tab-workbench #sec-conv{order:3}}.dash-page{max-width:1180px;margin:0 auto;padding:20px;display:flex;flex-direction:column;gap:20px}.dash-page>*{min-width:0}.dash-section>*{min-width:0}.dash-section{scroll-margin-top:130px;display:flex;flex-direction:column;gap:16px}.dash-section.card{padding:16px}.dash-cards{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}.dash-card{background:#fff;border:1px solid var(--line);border-radius:var(--radius);padding:16px;min-width:0}.dash-card-label{color:var(--muted);font-size:12px;font-weight:600}.dash-card-value{font-size:24px;font-weight:600;line-height:1.3;overflow-wrap:anywhere}.card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);box-shadow:none;padding:16px}.card h2{font-size:14px;margin:0 0 12px;font-weight:600}.card h3{font-size:14px;margin:24px 0 12px;padding-top:16px;border-top:1px solid var(--line);font-weight:600}.card h3:first-of-type{border-top:0;padding-top:0}.composer h2{font-size:16px}.composer textarea.prompt{min-height:148px;font-size:14px;padding:12px;border-radius:var(--radius)}.composer textarea.coord{font-family:var(--mono);font-size:13px}.bar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:12px 0}.bar>button,.bar>label{display:inline-flex;align-items:center;height:var(--ctl);min-height:var(--ctl);padding:0 16px;box-sizing:border-box;white-space:nowrap}.bar>input,.bar>select{height:var(--ctl);min-height:var(--ctl)}input,select,textarea,button{font:inherit;font-size:14px;padding:5px 12px;border-radius:var(--radius);border:1px solid var(--line);background:#fff;color:var(--text)}input:focus,select:focus,textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px #0969da4d}select{-moz-appearance:none;appearance:none;-webkit-appearance:none;cursLine truncated
</head>
<body>
+58
View File
@@ -944,6 +944,7 @@ POSTGRES_SCHEMA = (
"coordinator_parts TEXT, "
"parent_task_id TEXT, "
"coordinator_part TEXT, "
"kind TEXT, "
"goal_resume_pending INTEGER DEFAULT 0, "
"agent_name TEXT, "
"agent_version TEXT, "
@@ -954,6 +955,8 @@ POSTGRES_SCHEMA = (
"audit_loop_packet TEXT, "
"UNIQUE(project_id, idempotency_key))",
"CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status)",
"CREATE INDEX IF NOT EXISTS idx_tasks_parent"
" ON tasks(parent_task_id)",
# 附件包与任务附件:与 server._ensure_uploads_tables 同款,
# POSTGRES_SCHEMA 在每次初始化时全量执行,新表自动补建。
"CREATE TABLE IF NOT EXISTS uploads("
@@ -1162,6 +1165,49 @@ POSTGRES_SCHEMA = (
)
# 启动期 schema 初始化 advisory lock key(Postgres):
# 并发启动(cloud-server 与 worker 同时 init_db)时,CREATE TABLE /
# CREATE INDEX 的 IF NOT EXISTS 检查与建对象不是原子的——两个会话
# 都查到“不存在”后同时建,会撞 pg_type_typname_nsp_index(建表)
# 或 pg_class_relname_nsp_index(建索引)的 UniqueViolation,启动
# 即崩、靠 systemd 重启才自愈(37ba687 与 7983567 两次部署均撞上;
# 7983567 新增的 idx_tasks_parent 是当时的新对象)。
# 持锁串行化整个初始化:init_postgres_schema 内持事务级锁(随本
# 函数末尾的 commit 释放),server.init_db 另持会话级锁覆盖其后
# 的 _ensure_*(task_continue_messages / user_gateway_keys /
# task_attempts / task_audit_loop_state 不在 POSTGRES_SCHEMA 里,同样
# 会撞)。key 须全项目唯一:限流清理另有
# RATE_LIMIT_SWEEP_ADVISORY_KEY = 20261027(cloud/server.py),
# 两处绝不能撞 key。
SCHEMA_INIT_ADVISORY_KEY = 20261108
def acquire_schema_init_lock(conn):
"""取 schema 初始化会话级 advisory 锁(阻塞等,不 try)。
调用方须在初始化全部完成后调 release_schema_init_lock 释放;
连接关闭时也会自动释放。初始化必须做完不能跳过,所以这里用
阻塞取锁,不用 pg_try_advisory_lock。
"""
conn.execute(
"SELECT pg_advisory_lock(?)",
(SCHEMA_INIT_ADVISORY_KEY,))
def release_schema_init_lock(conn):
"""放 schema 初始化会话级 advisory 锁(best-effort)。
事务已 abort 时 SELECT 本身也会失败——吞掉,连接关闭时锁
同样释放,绝不能让放锁失败再挡住启动。
"""
try:
conn.execute(
"SELECT pg_advisory_unlock(?)",
(SCHEMA_INIT_ADVISORY_KEY,))
except Exception:
pass
def init_postgres_schema(conn):
"""在给定适配连接上建出全部 Postgres 表(幂等),并提交。
@@ -1175,6 +1221,15 @@ def init_postgres_schema(conn):
库是 no-op。server.init_db 的 Postgres 分支还会再跑一次
migrate_projects_table,两次补列互不冲突(第二次是 no-op)。
"""
# 事务级 advisory 锁:并发 init_postgres_schema 串行执行,
# 随本函数末尾的 commit 释放;独立调用方(如 migrations)
# 同样受保护。server.init_db 另持会话级锁覆盖 _ensure_*。
# 不取结果行:pg_advisory_xact_lock 返回 void,且假连接
# (test_p2_migrations / test_audit_batch1 的 FakeCursor)
# 没有 fetchone——execute 本身已阻塞到取锁成功。
conn.execute(
"SELECT pg_advisory_xact_lock(?)",
(SCHEMA_INIT_ADVISORY_KEY,))
for statement in POSTGRES_SCHEMA:
conn.execute(statement)
cols = {row[1] for row in conn.execute(
@@ -1376,6 +1431,9 @@ __all__ = [
"PgCursor",
"PgRow",
"POSTGRES_SCHEMA",
"SCHEMA_INIT_ADVISORY_KEY",
"acquire_schema_init_lock",
"release_schema_init_lock",
"init_postgres_schema",
"create_database",
"database_from_env",
+2
View File
@@ -199,6 +199,8 @@ def _task_to_dict(row, attachments=None, total_tokens=None):
if _row_get(row, "coordinator_parts") else None),
"parent_task_id": _row_get(row, "parent_task_id"),
"coordinator_part": _row_get(row, "coordinator_part"),
# 旁聊(Muse 角色对齐 P2):NULL = 老行 = 主聊。
"kind": _row_get(row, "kind") or "main",
"goal_resume_pending": _row_get(row, "goal_resume_pending") or 0,
# agent 定义 v2:身份与指令落库后直接回读(老行缺列
# 时为 None;指令是定义文本、非密钥,任务详情里可见)。
+24 -6
View File
@@ -346,8 +346,15 @@ def extract_task_creates(text):
executor = payload.get("executor")
if executor is not None and executor not in EXECUTORS:
continue
# 旁聊(P2 G7):块里可显式指定 kind("main"|"side"),
# 非法值整块跳过(与 executor 非法同款);不指定时
# worker 按父任务 kind 继承(旁聊父 → 旁聊子)。
kind = payload.get("kind")
if kind is not None and kind not in ("main", "side"):
continue
out.append({"name": name, "prompt": prompt, "model": model,
"effort": effort, "executor": executor})
"effort": effort, "executor": executor,
"kind": kind})
return out
@@ -1011,18 +1018,20 @@ def parent_spawn_generation(conn, parent_id):
只数 parent_task_id 非空且 coordinator_part 为空的祖先链(即
派生链);协调员子任务(coordinator_part 非空)本身不是派生
任务,代数为 0,链到此为止。走 32 步仍未到头时按已数到的算,
绝不死循环。"""
任务,代数为 0,链到此为止;旁聊任务(kind='side')同款——
旁聊不是 marker 派生,链到旁聊为止。走 32 步仍未到头时按已数
到的算,绝不死循环。"""
generation = 0
cur = parent_id
for _ in range(32):
if not cur:
break
row = conn.execute(
"SELECT parent_task_id, coordinator_part FROM tasks"
" WHERE id = ?", (cur,)).fetchone()
"SELECT * FROM tasks WHERE id = ?", (cur,)).fetchone()
if row is None:
break
if row_get(row, "kind") == "side":
break
parent = row_get(row, "parent_task_id")
part = row_get(row, "coordinator_part")
if parent is not None and part is None:
@@ -1034,7 +1043,16 @@ def parent_spawn_generation(conn, parent_id):
def count_direct_spawn_children(conn, parent_id):
"""单个父任务的直接派生子任务数(不含协调员子任务)。"""
"""单个父任务的直接派生子任务数(不含协调员子任务与旁聊)。"""
try:
row = conn.execute(
"SELECT COUNT(*) FROM tasks WHERE parent_task_id = ?"
" AND coordinator_part IS NULL"
" AND (kind IS NULL OR kind != 'side')",
(parent_id,)).fetchone()
except Exception:
# 老库/最小测试表缺 kind 列:当时不可能有旁聊行,
# 不带 kind 条件的结果与带条件等价。
row = conn.execute(
"SELECT COUNT(*) FROM tasks WHERE parent_task_id = ?"
" AND coordinator_part IS NULL", (parent_id,)).fetchone()
+6 -1
View File
@@ -236,7 +236,12 @@ def migrate_sqlite_to_postgres(sqlite_path, pg_conn=None, tables=None):
# 的 `or 0` 兜底拦不住,audit_loop=0 的
# 历史任务会被悄悄当成启用(Audit batch 1)。
("audit_loop", "INTEGER"),
("audit_loop_packet", "TEXT")):
("audit_loop_packet", "TEXT"),
# 旁聊(Muse 角色对齐 P2):kind 列须与
# db.py POSTGRES_SCHEMA、server
# migrate_tasks_table 同步;NULL = 老行
# = 主聊,无回填。
("kind", "TEXT")):
if _col not in _existing:
try:
pg_conn.execute(
+81 -9
View File
@@ -13,6 +13,7 @@
- CloudExecutor:占位,未接通云端执行。
"""
import hashlib
import json
import logging
import os
@@ -43,6 +44,39 @@ from cloud.peer_messages import extract_peer_messages as _extract_peer_messages
MAX_STDOUT_CHARS = 2000
# 租户 Docker 网络子网划段(2026-11-07 B 方案)。
#
# 背景:宿主 iptables 只放行 172.16.0.0/12 与 127.0.0.1 访问
# Tinyproxy(8889),容器内 codex 必须经该代理到模型网关。
# 以前建网不传 --subnet,子网由 Docker 默认池分配,曾分到
# 192.168.32.0/20(放行范围外),容器零次补全、单轮 600 秒
# 超时两轮后任务失败。划段区选 172.16.0.0/16:它在放行范围
# 内,且 Docker 默认池从 172.17.0.0/16 起分,永远不会自动分
# 到 172.16 段,与现存网络、宿主 LAN、Tailscale 均不重叠
# (2026-11-07 主机实测:10 个网络,172.16.0.0/16 空闲)。
# 每个租户网占一个 /24(254 个地址足够);同一网络名每次
# 算出的候选顺序相同(sha256 首字节起算 + 线性探测),不依
# 赖任何持久化状态;候选子网已被别的租户网占用时顺延下一
# 个。容量上限 256 个租户网/宿主(32 次探测封顶),满了就
# 明确返回失败,不静默降级——扩容需另行决策(删残留旧网或
# 扩大放行范围),不要在这里偷偷换段。
TENANT_SUBNET_PREFIX = "172.16"
TENANT_SUBNET_SLOTS = 256
TENANT_SUBNET_MAX_PROBES = 32
def tenant_subnet_candidates(name):
"""按网络名确定性排出候选租户子网(/24,172.16.0.0/16 内)。
首候选由 sha256(网络名) 首字节决定,其余按槽位顺延;
不同网络名一般落在不同槽,撞槽时靠 ensure_network 的
overlap 探测顺延。纯函数,不碰 Docker、不读状态。
"""
seed = hashlib.sha256(name.encode("utf-8")).digest()[0]
for i in range(TENANT_SUBNET_MAX_PROBES):
slot = (seed + i) % TENANT_SUBNET_SLOTS
yield "%s.%d.0/24" % (TENANT_SUBNET_PREFIX, slot)
def tmp_isolation_status():
"""各层 /tmp 隔离现状的唯一口径(README 与单测都以它为准)。
@@ -558,7 +592,7 @@ class LocalExecutor(Executor):
return re.sub(r"[^A-Za-z0-9_.-]", "-", raw)
def dispatch_task(self, task, seed_dir=None, extra_env=None,
model_provider=None) -> dict:
model_provider=None, no_worktree=False) -> dict:
"""调 host.py dispatch:建 host 任务并跑一轮 turn。
命令:host.py [--config CFG] [--model M] dispatch --name <host 名>
@@ -571,6 +605,8 @@ class LocalExecutor(Executor):
model 换成 fallback 模型,第二次 dispatch/run 就跑在新模型上)。
seed_dir 非空时追加 --seed-dir:host.py 在建 worktree 后、
跑 turn 前把暂存附件拷进去(run 续跑不走这条路,不重复拷)。
no_worktree(旁聊默认):追加 --no-worktree,host.py 不建
worktree/分支,无 repo 时以 per-task scratch 目录为 cwd。
"""
if not isinstance(task, dict) or task.get("id") is None:
raise ValueError("任务缺少 id")
@@ -591,6 +627,8 @@ class LocalExecutor(Executor):
cmd += ["--repo", repo]
if seed_dir:
cmd += ["--seed-dir", seed_dir]
if no_worktree:
cmd += ["--no-worktree"]
return self._result_from_runner(
task["id"], self._run_local(cmd, extra_env))
@@ -639,7 +677,7 @@ class LocalExecutor(Executor):
task["id"], self._run_local(cmd, extra_env))
def goal_task(self, task, seed_dir=None, extra_env=None,
model_provider=None) -> dict:
model_provider=None, no_worktree=False) -> dict:
"""调 host.py goal:目标循环(turn→验测→喂回重试)。
命令:host.py [--config CFG] [--model M] goal --name <host 名>
@@ -686,6 +724,8 @@ class LocalExecutor(Executor):
cmd += ["--repo", repo]
if seed_dir:
cmd += ["--seed-dir", seed_dir]
if no_worktree:
cmd += ["--no-worktree"]
return self._goal_result_from_runner(
task["id"], self._run_local(cmd, extra_env))
@@ -833,7 +873,8 @@ class LocalExecutor(Executor):
}
def popen_task(self, task, kind="dispatch", seed_dir=None,
extra_env=None, model_provider=None):
extra_env=None, model_provider=None,
no_worktree=False):
"""进程组启动 host.py 子进程,返回 Popen(供真取消用)。
start_new_session=True 让子进程自成进程组,worker 取消时
@@ -886,6 +927,8 @@ class LocalExecutor(Executor):
cmd += ["--repo", repo]
if seed_dir:
cmd += ["--seed-dir", seed_dir]
if no_worktree:
cmd += ["--no-worktree"]
elif kind == "goal-resume-breakpoint":
# G10 断点:无 --goal-text,不开新循环;host 侧按
# goal_rounds_done 的下一轮接着跑,轮次上限沿用原
@@ -915,6 +958,8 @@ class LocalExecutor(Executor):
cmd += ["--repo", repo]
if seed_dir:
cmd += ["--seed-dir", seed_dir]
if no_worktree:
cmd += ["--no-worktree"]
if extra_env:
merged = dict(os.environ)
merged.update(extra_env)
@@ -1471,13 +1516,17 @@ class DockerExecutor(Executor):
def dispatch_task(self, task, timeout=None, extra_mounts=None,
network=None, extra_env=None,
seed_host_dir=None, model_provider=None) -> dict:
seed_host_dir=None, model_provider=None,
no_worktree=False) -> dict:
"""容器内调 host.py dispatch:建 host 任务并跑一轮 turn。
seed_host_dir 非空时把暂存目录只读挂载进容器
(/tmp/codex-seed),内层 host.py 用 --seed-dir
/tmp/codex-seed 拷进 worktree;挂载整个目录(不是单个
文件)是为了一包多文件与子目录。"""
文件)是为了一包多文件与子目录。no_worktree(旁聊):
内层命令追加 --no-worktree,容器内 host.py 不建
worktree/分支(worker 只在本地默认旁聊上接线,显式
docker 旁聊走既有语义;此处只做透传)。"""
if not isinstance(task, dict) or task.get("id") is None:
raise ValueError("任务缺少 id")
if not task.get("prompt"):
@@ -1496,6 +1545,8 @@ class DockerExecutor(Executor):
if seed_host_dir:
inner += ["--seed-dir", "/tmp/codex-seed"]
mounts.append("%s:/tmp/codex-seed:ro" % seed_host_dir)
if no_worktree:
inner += ["--no-worktree"]
return self._run_in_container(task, inner, timeout=timeout,
extra_mounts=mounts or None,
network=network,
@@ -1539,11 +1590,13 @@ class DockerExecutor(Executor):
def goal_task(self, task, timeout=None, extra_mounts=None,
network=None, extra_env=None,
seed_host_dir=None, model_provider=None) -> dict:
seed_host_dir=None, model_provider=None,
no_worktree=False) -> dict:
"""容器内调 host.py goal:目标循环(turn→验测→喂回重试)。
seed_host_dir 语义同 dispatch_task(只读挂载 + 内层
--seed-dir /tmp/codex-seed)。
--seed-dir /tmp/codex-seed)。no_worktree 语义同
dispatch_task(内层命令追加 --no-worktree)。
不走 _run_in_container:它经 _parse_host_result 截头
stdout,而 goal 结论在末尾,截头会把结论截掉;这里直接
@@ -1584,6 +1637,8 @@ class DockerExecutor(Executor):
if seed_host_dir:
inner += ["--seed-dir", "/tmp/codex-seed"]
mounts.append("%s:/tmp/codex-seed:ro" % seed_host_dir)
if no_worktree:
inner += ["--no-worktree"]
cmd = self.build_run_command(task, inner,
extra_mounts=mounts or None,
network=network,
@@ -1957,6 +2012,15 @@ class DockerExecutor(Executor):
def ensure_network(self, name):
"""确保 Docker 网络存在:不存在就创建,已存在就忽略。
创建时显式 --subnet 固定在 172.16.0.0/12 内划段(见
tenant_subnet_candidates):宿主 iptables 只放行该范围
访问 egress 代理,不传子网时 Docker 默认池可能分到范
围外(如 192.168.32.0/20),容器连不上代理、任务空等
600 秒超时。候选子网已被占用时按 overlap 报错顺延;
已存在(同名)直接复用——注意:这意味着 2026-11-07
之前建好的范围外旧网(如 codex-smoke-20261107)不会
被自动纠正,须人工删除后重建,见部署清单。
返回 True 表示网络可用(创建成功或本来就存在),
False 表示创建失败(调用方继续派发,docker run 时会
因网络不存在而明确失败,不在这里静默降级默认网络——
@@ -1966,8 +2030,11 @@ class DockerExecutor(Executor):
"""
if not name:
return False
for subnet in tenant_subnet_candidates(name):
try:
result = self._call_runner(["docker", "network", "create", name],
result = self._call_runner(
["docker", "network", "create",
"--subnet", subnet, name],
timeout=30)
except Exception as exc:
return "already exists" in str(exc)
@@ -1976,9 +2043,14 @@ class DockerExecutor(Executor):
return True
text = (getattr(result, "stderr", "") or "") + \
(getattr(result, "stdout", "") or "")
return "already exists" in text
if "already exists" in text:
return True
if "overlap" in text.lower():
continue
return False
except Exception:
return False
return False
def execute(self, task) -> dict:
"""只读执行:容器内调 host.py status,不直接跑模型回合。
+157 -5
View File
@@ -88,7 +88,9 @@ from cloud.timeouts import validate_server_timeout as _validate_timeout
from cloud.auth import audit_on_conn as _audit_on_conn
from cloud.db import connect as _db_connect
from cloud.db import create_database as _create_database
from cloud.db import acquire_schema_init_lock as _acquire_schema_init_lock
from cloud.db import init_postgres_schema as _init_postgres_schema
from cloud.db import release_schema_init_lock as _release_schema_init_lock
from cloud.db import PostgresIntegrityError as _PgIntegrityError
from cloud.db import PostgresOperationalError as _PgOperationalError
from cloud.goal_command import parse_goal_command as _parse_goal_command
@@ -1234,6 +1236,11 @@ def migrate_tasks_table(conn):
"coordinator_parts": "TEXT",
"parent_task_id": "TEXT",
"coordinator_part": "TEXT",
"kind": "TEXT",
# 旁聊(Muse 角色对齐 P2):kind 记任务类型
# ('main' | 'side',NULL = 老行 = 主聊,与 goal_mode
# NULL=0、plan NULL=0 同款惯例);parent_task_id
# 的语义从“协调员/派生父任务”扩为“父会话”。
# G10:goal 断点续跑待处理标记(1 = 已调 /goal-resume、
# 待 worker 按断点续跑;worker 认领后清掉,跑完不再
# 重复触发)。与 resume_message 不同:后者是“给旧会
@@ -1662,6 +1669,14 @@ def init_db(db_path):
conn = _connect(db_path)
try:
if _is_postgres_backend():
# 会话级 advisory 锁覆盖整个 PG 初始化:_init_postgres_schema
# 内还有事务级锁,但其后的 _ensure_* 建的对象不全在
# POSTGRES_SCHEMA 里(task_continue_messages /
# user_gateway_keys / task_attempts / task_audit_loop_state),
# 只锁前者仍会在后者上撞 UniqueViolation;锁在最终 commit
# 后才放。连接在外层 finally 里关闭,锁至迟随连接释放。
_acquire_schema_init_lock(conn)
try:
_init_postgres_schema(conn)
migrate_tasks_table(conn)
migrate_projects_table(conn)
@@ -1678,6 +1693,8 @@ def init_db(db_path):
_ensure_audit_loop(conn)
conn.commit()
return
finally:
_release_schema_init_lock(conn)
conn.execute(
"CREATE TABLE IF NOT EXISTS projects("
"id TEXT PRIMARY KEY, "
@@ -1725,6 +1742,9 @@ def init_db(db_path):
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_tasks_status"
" ON tasks(status)")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_tasks_parent"
" ON tasks(parent_task_id)")
conn.commit()
finally:
conn.close()
@@ -2320,6 +2340,20 @@ def _goal_signal_status(status):
return "pending_mirror_to_host_loop"
def _filter_tasks_by_kind(rows, kind_f):
"""旁聊(Muse 角色对齐 P2):GET /tasks 的 kind 过滤。
kind_f 为空时原样返回(缺省 = 全部,老行为);否则只留
`_task_to_dict` 的 kind 等于 kind_f 的行。抽成独立函数,
不让 `_list_tasks` 再长(长方法清单 pin 在 18 个,见
test_longfunc_inventory)。
"""
if not kind_f:
return rows
return [r for r in rows
if _task_to_dict(r, [], 0)["kind"] == kind_f]
class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, AutomationMixin, FullAutoMixin,
BaseHTTPRequestHandler):
"""控制面请求处理:除 /health 与 /webhooks/* 外一律要求 X-API-Key。"""
@@ -3812,6 +3846,69 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
"error": "协调员任务不跑模型回合,不能与"
" goal/plan/验收命令同用"})
return
# 父任务血缘(Muse 角色对齐 P1):创建请求可携带
# parent_task_id;父任务是协调员且本次新建也是协调员
# 时拒绝(深度恒 ≤1)。父不存在 404、跨项目 400。
# 不带 coordinator_parts 时 parent_task_id 仅作血缘记录
# (第二阶段旁聊的父子关系用同一入参)。
parent_task_id = body.get("parent_task_id")
if parent_task_id is not None:
if not isinstance(parent_task_id, str):
self._send_json(400, {
"error": "parent_task_id must be a string"})
return
parent_row = self._fetch_task(parent_task_id)
if parent_row is None:
self._send_json(404, {"error": "parent task not found"})
return
parent_task = _task_to_dict(parent_row)
if parent_task["project_id"] != project_id:
self._send_json(400, {
"error": "parent task belongs to another project"})
return
if coord_parts and parent_task.get("coordinator_parts"):
self._send_json(400, {
"error": "coordinator task cannot be created"
" under a coordinator task"})
return
# 旁聊(Muse 角色对齐 P2):kind 只认 "main" | "side",
# 缺省 = 主聊;旁聊须带 parent_task_id(已在上段校验
# 存在与同项目),不得带 coordinator_parts(协调员
# 子任务与旁聊是两种父子关系),不得带 upload_id /
# environment_id(附件与环境语义都要求 worktree,
# 旁聊默认没有);父任务不得是协调员任务(协调员
# 本身不跑模型回合,旁聊挂上去没有父会话语义,且
# 会污染协调员的子任务同步快照)。
kind = body.get("kind")
if kind is None:
kind = "main"
if kind not in ("main", "side"):
self._send_json(400, {
"error": "kind must be 'main' or 'side'"})
return
if kind == "side":
if parent_task_id is None:
self._send_json(400, {
"error": "side thread requires parent_task_id"})
return
if coord_parts:
self._send_json(400, {
"error": "side thread cannot carry coordinator_parts"})
return
if parent_task is not None and parent_task.get(
"coordinator_parts"):
self._send_json(400, {
"error": "side thread parent cannot be a"
" coordinator task"})
return
if body.get("upload_id") is not None:
self._send_json(400, {
"error": "side thread cannot carry upload_id"})
return
if body.get("environment_id") is not None:
self._send_json(400, {
"error": "side thread cannot carry environment_id"})
return
idempotency_key = body.get("idempotency_key")
if idempotency_key is not None and not isinstance(idempotency_key, str):
self._send_json(400, {"error": "idempotency_key must be a string"})
@@ -3893,7 +3990,10 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
# 未指定且无环境继承时默认 docker:空 executor(任意
# worker 可认领)会让任务落到隔离较弱的 local worker,
# 是多用户下的隔离缺口;显式 local/cloud 不受影响。
executor = _default_executor()
# 旁聊例外(P2 G1):旁聊是会话式使用,默认 local、
# 不拉容器;docker/cloud 仅显式要求时才走。
executor = "local" if kind == "side" \
else _default_executor()
# 附件绑定(upload_id 由 POST /uploads 发出):包必须存在、
# 归属当前用户、未使用、24 小时内、暂存目录还在;cloud
# 执行器没有 worktree 可 seed,直接拒绝。校验在 INSERT
@@ -4019,19 +4119,19 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
" goal, verify_command, goal_max_rounds,"
" model, effort, approval, plan, goal_mode,"
" environment_id, coordinator_parts,"
" parent_task_id, coordinator_part,"
" parent_task_id, coordinator_part, kind,"
" child_model, child_effort,"
" agent_name, agent_version, agent_instructions,"
" agent_tools, agent_tool_approvals, audit_loop)"
" VALUES(?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?, NULL, ?,"
" ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
" ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(task_id, project_id, prompt, idempotency_key,
json.dumps(depends_on), priority, user_id, now, now,
executor, goal, verify_command, goal_max_rounds,
model, effort, approval, plan, goal_mode,
environment_id,
json.dumps(coord_parts) if coord_parts else None,
None, None, child_model, child_effort,
parent_task_id, None, kind, child_model, child_effort,
agent_name_val, agent_version_val,
agent_instructions_val, agent_tools_val,
agent_tool_approvals_val, audit_loop_val),
@@ -4120,6 +4220,13 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
parent_ids = query.get("parent_task_id")
if parent_ids:
parent_task_id = parent_ids[0]
# 旁聊(Muse 角色对齐 P2):kind=main|side 可选,缺省 =
# 全部(老行为不变);非法值 400。
kind_f = query["kind"][0] if query.get("kind") else None
if kind_f is not None and kind_f not in ("main", "side"):
self._send_json(400, {
"error": "kind must be 'main' or 'side'"})
return
limit = None
offset = 0
if query.get("limit"):
@@ -4156,13 +4263,15 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
s = r["status"]
status_counts[s] = status_counts.get(s, 0) + 1
if parent_task_id:
filtered = rows
filtered = _filter_tasks_by_kind(rows, kind_f)
else:
filtered = []
for r in rows:
d = _task_to_dict(r, [], 0)
if status_f and d["status"] != status_f:
continue
if kind_f and d["kind"] != kind_f:
continue
if executor_f and (d["executor"] or "") != executor_f:
continue
if q_f and q_f not in ((d["id"] or "") + " " + (d["prompt"] or "")).lower():
@@ -4440,6 +4549,15 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
return
if self._reject_if_over_quota_for_task(user_id, task):
return
# 协调员任务不接受后续消息(Muse 角色对齐 P1):写入侧
# 会 201 成功、消费侧没有任何读点,宁可入口 409 明拒。
# 改分工请取消后重建协调员;给分工追加指令请直接发给
# 该子任务。
if task.get("coordinator_parts"):
self._send_json(409, {
"error": "coordinator tasks do not accept"
" follow-up messages"})
return
text, mode, body_err = _validate_followup_body(body)
if body_err is not None:
self._send_json(body_err[0], body_err[1])
@@ -5922,6 +6040,12 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
(http_code, payload),不直接 _send_json;配额检查
由调用方在调本方法前做。"""
task_id = task["id"]
# 协调员任务不接受后续消息(Muse 角色对齐 P1):与
# _create_followup 同款入口 409,见该处注释。
if task.get("coordinator_parts"):
return 409, {
"error": "coordinator tasks do not accept"
" follow-up messages"}
goal_target = _parse_goal_command(text)
# /goal 前置判定见 _classify_goal_followup(纯计算,与
# _create_followup 共用,但只用于 goal_target 非空时:
@@ -6444,6 +6568,14 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
if err is not None:
self._send_json(err[0], {"error": err[1]})
return
# 旁聊(P2 G5):旁聊没有 worktree,/worktree 按
# “没有本地 worktree”口径回 404,不回 exists=false
# 的摘要(/files 与 /tree 同款分支已在
# _task_worktree_for_files 里回 404)。
if task.get("kind") == "side":
self._send_json(404, {
"error": "no local worktree for this task"})
return
summary = self._worktree_summary(task)
self._send_json(200, dict(summary, task_id=task_id))
@@ -6739,6 +6871,13 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
return
if self._reject_if_over_quota(user_id):
return
# 旁聊(P2 G6):旁聊无 worktree/分支,分叉 thread
# 历史的变体不在首版,直接 409,不等 worker/host 层
# 以 500/ValueError 收尾。
if task.get("kind") == "side":
self._send_json(409, {
"error": "side thread has no worktree/branch to fork"})
return
message = body.get("message")
if not message or not isinstance(message, str) \
or not message.strip():
@@ -6987,6 +7126,13 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
user_id, task["project_id"], "approve_merge"):
self._send_json(403, {"error": "forbidden"})
return
# 旁聊(P2 G6):旁聊无 worktree/分支,合并门禁的五项
# 证据(CI/目录/契约/批准/评审)无一有落点,直接 409,
# 不进门禁检查。
if task.get("kind") == "side":
self._send_json(409, {
"error": "side thread has no worktree/branch to merge"})
return
_merge_ok, _merge_reason = _merge_state_decision(
task["status"])
if not _merge_ok:
@@ -8820,6 +8966,12 @@ pid 须为整数、address 须为字符串,否则 400(注册表里的
user_id, task["project_id"], "approve_merge"):
self._send_json(403, {"error": "forbidden"})
return
# 旁聊(P2 G6):旁聊无 worktree/分支,建 PR 无分支可推,
# 直接 409,不等 host 层以 500/ValueError 收尾。
if task.get("kind") == "side":
self._send_json(409, {
"error": "side thread has no worktree/branch for a PR"})
return
if task["status"] != "merged":
self._send_json(409, {
"error": "only merged tasks can create a PR",
+89 -17
View File
@@ -367,16 +367,22 @@ G0 的六问缺一问,G0 就不算过。用户没回复时,协调员等,
角色模板的权威来源是本文件末尾的附录。附录随 SKILL.md 一起播种,
所以租户任务内一定能看到。`templates/roles/` 下的文件是编辑用的源文件,
发布前必须核对附录和源文件一致,不一致时以附录为准并先修源文件。
协调员派生子任务时,必须把对应角色模板全文贴进子任务提示词,
并补上本任务的输入、目录、分支和完成定义。
协调员派生子任务时,主聊人工派生须把对应角色模板全文贴进子任务提示词,
并补上本任务的输入、目录、分支和完成定义;平台协调员任务的分工 prompt
即子任务 prompt 全文,角色模板不由平台注入,如需模板内容,创建人须自己
写进分工 prompt。
只写一句“按 software-engineering skill 做”是不够的,
子任务不一定会读到本技能。
术语:本技能说的“线程”就是平台的“任务(task)”;“协调员任务”就是
`coordinator_parts` 非空的任务;“分工”就是 `coordinator_parts` 的一项;
“子任务”就是 `parent_task_id` 指向协调员的任务;`forked_from` 链与
`parent_task_id` 是两条血缘,不要混用。
## 协调员派生子任务的必填字段
每个子任务提示词必须包含,缺一项就不派生:
1. 角色模板全文(来自本文件附录,租户任务内可直接引用)。
1. 角色模板内容(主聊人工派生时贴模板全文,来自本文件附录,租户任务内可直接引用;平台协调员任务的分工 prompt 即子任务 prompt 全文,角色模板不由平台注入,如需模板内容,创建人须自己写进分工 prompt)。
2. 本阶段输入:PRD 哪个版本、契约哪个版本、设计稿哪个版本。
3. 拥有目录:只能写这一个目录;只读目录:契约、他人目录。
4. 分支名:`feat/swe/<阶段>-<角色>-<简称>`,例如 `feat/swe/client-web`、
@@ -400,6 +406,8 @@ G0 的六问缺一问,G0 就不算过。用户没回复时,协调员等,
| 完成定义 | 产出文件清单 + 必须跑过的自测命令 + 必须提交的证据 |
| TODO-UPDATE 要求 | 结尾报告必须带 TODO-UPDATE 小节,格式见“待办机制” |
校验表里的拥有目录 / 分支名 / 版本 / TODO-UPDATE 是写入分工 prompt 的约定内容,平台只校验分工字段(`coordinator.validate_parts`),不校验、不解析这些项;“缺一项就不派生”在平台侧不成立——平台只校验 `validate_parts` 的字段。
子任务精简包(裁剪规则写死,不是“尽量精简”):只给本端相关的
契约章节、设计页、PRD 故事和冻结决策摘要,绝不把 PRD、契约、
设计全文复制给每个线程。精简包由四部分组成,缺一部分就补齐再发:
@@ -442,13 +450,19 @@ TODO-UPDATE
- 冻结前:架构线程可以自由改契约,改完要通知协调员更新版本号。
- 冻结物(G2 必须齐):API 契约、数据模型、agent 协议、
任务状态机、错误 taxonomy、技术栈表、每端能力矩阵。
(7 项齐全由协调员人工核对;平台只校验契约版本号与冻结标志,
不校验冻结物清单、不发回归任务。)
任务状态机至少包含:创建、排队、运行中、等审批、已完成、
已取消、失败、超时;错误 taxonomy 至少区分:模型失败、工具失败、
用户取消、超时、权限拒绝、配额超限。同一错误在各端不得换一个名字。
- 冻结后:任何线程想改契约,必须给协调员发任务间消息,写清:
改什么、为什么、影响哪些客户端、是否向后兼容。
(收件方是扮演协调员的模型/人工;平台协调员任务本身暂不支持
后续指令,见“任务间问答默认策略与决策记录”一节。)
- 协调员批准后:契约版本号加一(v1 -> v2),架构线程改契约,
受影响的客户端各收到一条变更通知和一个回归任务。
(版本号加一与回归任务派发是人工/协调员操作,平台不执行;
平台只校验版本号与冻结标志。)
- 协调员不批准:提议线程按旧契约做,差异记入已知问题,不悄悄分叉。
## 任务间问答默认策略与决策记录(v0.3 新增)
@@ -458,6 +472,7 @@ TODO-UPDATE
协调员在下一轮答复:确认假设、纠正假设(发回归任务)、或升级为契约变更;
答复本身也要记入决策记录。等答复期间线程不阻塞在原地,先做契约内
无争议的部分。
- 平台协调员任务暂不支持后续指令:给进行中的协调员任务发后续消息会被入口直接拒绝(409),因为平台循环内没有消费点。替代方案见附录 00“任务间问答与决策记录”一节。
- 决策记录落盘路径:`docs/decisions.md`,由协调员维护,子线程只在报告里
提议、不直接改。只追加不改写历史:一条决策写清日期、议题、决定、理由、
影响端;推翻旧决策时追加新条写明“取代 D-n”,绝不改旧条原文、不删旧条。
@@ -531,6 +546,8 @@ tests/ # QA 线程专属,含跨端一致性用例
(只改拥有目录,契约变更经批准除外;检查命令与输出文件清单必须
记入合并记录,见“目录与分支规则”);③ 契约版本是冻结版;
④ 协调员评审通过;⑤ 无未解决评审意见。有一项不过就不合并。
协调员批准与契约冻结版本是每次调合并门禁时提交的证据,平台不持久化;
谎报由审计与 PR/Checks 只读核对事后发现。
- CI 怎么查:用只读方式查,例如本平台的 GET /tasks/{id}/pr/checks。
它是只读诊断,绝不重跑检查、不批准、不合并;还没建 PR 时它会 409
(查无可查),这时先补 PR,不要把它当成 CI 通过。
@@ -538,9 +555,8 @@ tests/ # QA 线程专属,含跨端一致性用例
要求 PR、要求 CI 通过、禁止直接推。目标仓库没开保护时,
协调员要在决策记录里写明风险并去开通,不能假装已保护,
不能靠“大家自觉”代替保护。
- 合并门禁(2026-10-30 补,代码在 cloud/merge_gate.py,服务端
POST /tasks/{id}/merge 已接线;2026-10-31 硬门禁轨加
cloud/merge_gate_hard.py):保护状态只能由调用方或环境变量
- 合并门禁(服务端 POST /tasks/{id}/merge 已接线,门禁实现在
cloud/merge_gate_hard.py,默认 hard):保护状态只能由调用方或环境变量
CLOUD_BRANCH_PROTECTION 显式声明,代码不去 GitHub 核实、不声称
已保护。模式只由环境变量 CLOUD_MERGE_GATE_MODE 控制,默认
hard:上面五项全查,缺一 409 且任务状态不变,并写
@@ -624,8 +640,10 @@ tests/ # QA 线程专属,含跨端一致性用例
- G5 缺陷回路:缺陷回对应客户端或架构线程修,修完回 G5 回归,
回归范围按契约变更影响面定,不是每次都全量重测。
- 成本控制:分阶段本身就是成本控制。G4a 先验证,G4b / G4c 复用已验证物;
协调员、产品、架构用较强模型,重复性强的客户端实现和 QA 可用
较经济的模型,具体以项目实际可用模型和配额为准;
模型与强度按平台字段走:分工不写 model/effort 时,继承协调员任务的
child_model/child_effort 或自身模型;“协调员、产品、架构用较强模型,
重复性强的客户端实现和 QA 可用较经济的模型”只是建议,具体以项目
实际可用模型和配额为准;
配额将满时先停新线程,不先透支再解释。
@@ -750,6 +768,8 @@ tests/ # QA 线程专属,含跨端一致性用例
出一份单独签字的审计结论。谁写、谁测、谁审、谁执行、谁签字
五职分开:写是开发线程,测是 QA 线程,审是审计线程,
执行合并/发布是人或宿主操作,签字是审计签字人。
- 平台的 `audit_loop` 是协调员任务的自动重排循环,不是独立审计线程;
独立审计仍须另开线程,两者阻塞关系均为“与”,不可互换。
- 同一人不兼两职,可核查:审计记录的签字人字段必须与任务执行人
字段、协调员批准人字段、QA 测试人字段都不同,有一项相同,
审计结论本身无效,按审计 fail 处理。代码里这就是
@@ -1263,6 +1283,31 @@ full-auto-executor + installation id。质量门禁全部自动且阻塞合并
建仓分支要诚实(组织仓可经 App token 建,用户个人仓需安装会话里先建空仓,
记为 needs_user_once,不说成已自动建仓)。
## 平台协调员(以平台代码为准,skill 不得另说一套)
- 平台协调员任务(`coordinator_parts` 非空的任务)不跑模型回合:
worker 按分工自动扇出并行子任务、等齐、合流成一份汇总报告写回
父任务 `result_text`,汇总报告就是协调员任务的终局产物;合并决定
仍由你(模型/人工)在评审环节给,但评审发生在子任务终态之后,
报告之前没有“逐个放行”。
- 分工一次建齐、并行认领;分工最多 20 个;子任务 prompt 即分工 prompt
原文,平台不贴角色模板、不贴校验表、不贴精简包。
- 有界委派(建任务时声明,事后不可改):单分工超时 `timeout_seconds`
(1..86400 秒,超时只取消该分工、其余继续等)、失败容忍
`on_failure`(`fail` 默认 / `tolerate`:容忍分工失败不拖垮整体,
重跑时其 `failed` 子任务不重排、`interrupted` 的仍重排)、单项截断
`result_chars`(默认 500、上限 2000)。全局超时 3600 秒:到时取消
挂起子任务、父任务失败。平台不提供分工 token/费用预算、分工轮数
上限、按分工隔离写范围——不要许诺这三类边界。
- 配额预留:分工数超过 1 时,剩余配额须覆盖分工数 × $1(可经环境变量
调),不足建任务即 402;子任务费用记父任务创建人账。
- 取消与孤儿收口由平台自动做:取消/超时终局取消未终态子任务并标
`interrupted`,常驻循环每轮扫尾父已终态、子仍未终态的孤儿;你不需、
也不能手动“取消子任务”。
- 模型与强度:分工不写 `model`/`effort` 时,继承协调员任务的
`child_model`/`child_effort` 或自身模型;“协调员、产品、架构用
较强模型”只是建议,不是平台字段。
## 你必须先做的事
1. 按 `software-engineering` 技能的 G0 问卷问清:目标用户、核心能力、
@@ -1281,12 +1326,17 @@ full-auto-executor + installation id。质量门禁全部自动且阻塞合并
完成定义。之后所有状态以这份待办为准,不以对话为准。
你是待办文件的唯一写作者,子线程只在结尾报告里写 TODO-UPDATE,
你核对证据后才勾选。不要虚构任何待办标记语法,不要依赖模型会自动写标记块。
3. 每个子任务提示词必须包含:角色模板全文(来自 SKILL.md 附录)、
本阶段输入的版本号、拥有目录、只读目录、分支名、完成定义、
禁止事项、TODO-UPDATE 要求。缺一项不派生。
3. 每个子任务提示词必须包含:本阶段输入的版本号、拥有目录、
只读目录、分支名、完成定义、禁止事项、TODO-UPDATE 要求,
以及角色模板内容——主聊人工派生时贴模板全文;平台协调员任务
的分工 prompt 即子任务 prompt 全文,角色模板不由平台注入,
如需模板内容,创建人须自己写进分工 prompt。缺一项不派生。
派生前校验表(必填表,填好的表原样贴进提示词,缺一项就不发任务):
客户端名、拥有目录、分支名、契约版本、PRD 版本、设计版本、
完成定义、TODO-UPDATE 要求,共 8 项。
完成定义、TODO-UPDATE 要求,共 8 项。校验表里的拥有目录 /
分支名 / 版本 / TODO-UPDATE 是写入分工 prompt 的约定内容,
平台只校验分工字段(`coordinator.validate_parts`),不校验、
不解析这些项。
子任务精简包(裁剪规则写死):只给本端相关的契约章节、设计页、
PRD 故事和冻结决策摘要(不超过 10 条),绝不把 PRD、契约、设计全文
复制给每个线程;提示词里必须列出精简包所含章节清单,只写“已精简”不算数;
@@ -1298,7 +1348,8 @@ full-auto-executor + installation id。质量门禁全部自动且阻塞合并
- G2:架构师和 UI/UX 设计 2 个线程并行,中间加一次联合评审:
架构的状态机和设计的审批界面对得上才能冻结;收敛后你判定契约是否冻结,
冻结物必须齐:API 契约、数据模型、agent 协议、任务状态机、错误 taxonomy、
技术栈表、每端能力矩阵。
技术栈表、每端能力矩阵。(7 项齐全由你人工核对;平台只校验契约版本号
与冻结标志,不校验冻结物清单、不发回归任务。)
- G3:共享核心 1 个线程(含 mock 服务和契约测试);后端线程如有,
在 G3 阶段就参与契约测试,不要等到 G4 才对接。
- G3.5:可行性 spike。核心链路真跑通(agent 工具就是任务能创建、能跑出进度、
@@ -1327,6 +1378,9 @@ full-auto-executor + installation id。质量门禁全部自动且阻塞合并
(问题清单、热修复记录、回滚触发是否命中)+ v1.1 交接清单齐全,
两项缺一不进 G8;G8 的完成不是下一版已发布,是下一轮 G0-G6 的输入
已冻结齐全。
(阶段顺序是流程编排;平台协调员任务建出后各分工并行执行,不按 G1–G8
分阶段放行——需要分阶段时,分多次建协调员任务,上一阶段的协调员终态
后再建下一阶段。)
## 你的合并规则(评审与执行拆分)
@@ -1357,6 +1411,11 @@ full-auto-executor + installation id。质量门禁全部自动且阻塞合并
- 决策记录落盘 `docs/decisions.md`,由你维护,只追加不改写历史;
一条决策写清日期、议题、决定、理由、影响端;推翻旧决策追加新条
写明“取代 D-n”,绝不改旧条原文、不删旧条。
- 平台协调员任务暂不支持后续指令:给进行中的协调员任务发后续消息
会被入口直接拒绝(409),因为平台循环内没有消费点,写了也等于没发。
替代方案:改分工请取消协调员任务后重建一个新的;给某个进行中分工
追加指令请直接发给该子任务;已终态的协调员不要走复活,重新建一个
协调员任务,或对相关子任务复活后重跑。
## 跨任务产物传递(协调员)
@@ -1376,7 +1435,12 @@ full-auto-executor + installation id。质量门禁全部自动且阻塞合并
- 有进度的中断任务:断点续跑。
- 从未开始的任务:全新重跑,不直接判死。
- 单个客户端失败:只重开它,不重开全部。
- 单个客户端失败:只重开它,不重开全部——这是流程口径;平台语义
(`cloud/coordinator.py` 的 `evaluate_outcome`)是:任一非容忍分工
未完成,协调员任务即失败;`on_failure=tolerate` 的分工失败不拖垮
整体,重跑时其 `failed` 子任务不重排、`interrupted` 的仍重排;
未知分工名按失败处理;`merged` 视同完成。`on_failure=tolerate`
须在建协调员任务时声明,事后不可改。
## 你的交付物
@@ -1387,6 +1451,12 @@ G8 的交付物是 v1.1 交接清单终版与下一轮 G0-G6 输入冻结清单
---
## 独立审计与平台 audit_loop 的区分
- 平台的 `audit_loop` 是协调员任务的自动重排循环(推回包拆分到分工、
子任务重排),不是独立审计线程;独立审计仍须另开线程(附录 11-auditor),
审计 fail 自动推回并阻塞合并/发布,签字人与执行人、协调员批准人、
QA 测试人全不同。两者阻塞关系均为“与”,不可互换。
## GitHub 保证(协调员)
@@ -1401,13 +1471,15 @@ G8 的交付物是 v1.1 交接清单终版与下一轮 G0-G6 输入冻结清单
测试证据、已知问题、关联待办或 PRD 项;评审意见逐条处理,
未解决的评审意见不能合并。
- 合并门:GitHub Checks 全绿、目录归属检查通过、契约版本是冻结版、
你评审通过、无未解决评审意见,五项缺一不可。
你评审通过、无未解决评审意见,五项缺一不可。协调员批准与契约冻结
版本是每次调合并门禁时提交的证据,平台不持久化;谎报由审计与
PR/Checks 只读核对事后发现。
CI 用只读方式查,例如 GET /tasks/{id}/pr/checks,它绝不重跑检查、
不批准、不合并;无 PR 时它会 409,先补 PR。
- 主分支保护:要求 PR、要求 CI 通过、禁止直接推,这是 GitHub 仓库设置,
不是本平台代码能替你开的。没开保护时记为风险并去开通,不假装已保护。
- 合并门禁:同正文,代码在 cloud/merge_gate.py 与
cloud/merge_gate_hard.py,模式由 CLOUD_MERGE_GATE_MODE 控制,
- 合并门禁:同正文,门禁实现在 cloud/merge_gate_hard.py(默认 hard),
模式由 CLOUD_MERGE_GATE_MODE 控制,
默认 hard:五项全查、缺一 409、状态不变、写 merge_gate_blocked
审计,无证据不放行,unknown 不等于 protected,未保护即使带
显式确认也不放行;record / enforce / off 须显式设置才生效,
@@ -14,6 +14,31 @@ full-auto-executor + installation id。质量门禁全部自动且阻塞合并
建仓分支要诚实(组织仓可经 App token 建,用户个人仓需安装会话里先建空仓,
记为 needs_user_once,不说成已自动建仓)。
## 平台协调员(以平台代码为准,skill 不得另说一套)
- 平台协调员任务(`coordinator_parts` 非空的任务)不跑模型回合:
worker 按分工自动扇出并行子任务、等齐、合流成一份汇总报告写回
父任务 `result_text`,汇总报告就是协调员任务的终局产物;合并决定
仍由你(模型/人工)在评审环节给,但评审发生在子任务终态之后,
报告之前没有“逐个放行”。
- 分工一次建齐、并行认领;分工最多 20 个;子任务 prompt 即分工 prompt
原文,平台不贴角色模板、不贴校验表、不贴精简包。
- 有界委派(建任务时声明,事后不可改):单分工超时 `timeout_seconds`
(1..86400 秒,超时只取消该分工、其余继续等)、失败容忍
`on_failure`(`fail` 默认 / `tolerate`:容忍分工失败不拖垮整体,
重跑时其 `failed` 子任务不重排、`interrupted` 的仍重排)、单项截断
`result_chars`(默认 500、上限 2000)。全局超时 3600 秒:到时取消
挂起子任务、父任务失败。平台不提供分工 token/费用预算、分工轮数
上限、按分工隔离写范围——不要许诺这三类边界。
- 配额预留:分工数超过 1 时,剩余配额须覆盖分工数 × $1(可经环境变量
调),不足建任务即 402;子任务费用记父任务创建人账。
- 取消与孤儿收口由平台自动做:取消/超时终局取消未终态子任务并标
`interrupted`,常驻循环每轮扫尾父已终态、子仍未终态的孤儿;你不需、
也不能手动“取消子任务”。
- 模型与强度:分工不写 `model`/`effort` 时,继承协调员任务的
`child_model`/`child_effort` 或自身模型;“协调员、产品、架构用
较强模型”只是建议,不是平台字段。
## 你必须先做的事
1. 按 `software-engineering` 技能的 G0 问卷问清:目标用户、核心能力、
@@ -32,12 +57,17 @@ full-auto-executor + installation id。质量门禁全部自动且阻塞合并
完成定义。之后所有状态以这份待办为准,不以对话为准。
你是待办文件的唯一写作者,子线程只在结尾报告里写 TODO-UPDATE,
你核对证据后才勾选。不要虚构任何待办标记语法,不要依赖模型会自动写标记块。
3. 每个子任务提示词必须包含:角色模板全文(来自 SKILL.md 附录)、
本阶段输入的版本号、拥有目录、只读目录、分支名、完成定义、
禁止事项、TODO-UPDATE 要求。缺一项不派生。
3. 每个子任务提示词必须包含:本阶段输入的版本号、拥有目录、
只读目录、分支名、完成定义、禁止事项、TODO-UPDATE 要求,
以及角色模板内容——主聊人工派生时贴模板全文;平台协调员任务
的分工 prompt 即子任务 prompt 全文,角色模板不由平台注入,
如需模板内容,创建人须自己写进分工 prompt。缺一项不派生。
派生前校验表(必填表,填好的表原样贴进提示词,缺一项就不发任务):
客户端名、拥有目录、分支名、契约版本、PRD 版本、设计版本、
完成定义、TODO-UPDATE 要求,共 8 项。
完成定义、TODO-UPDATE 要求,共 8 项。校验表里的拥有目录 /
分支名 / 版本 / TODO-UPDATE 是写入分工 prompt 的约定内容,
平台只校验分工字段(`coordinator.validate_parts`),不校验、
不解析这些项。
子任务精简包(裁剪规则写死):只给本端相关的契约章节、设计页、
PRD 故事和冻结决策摘要(不超过 10 条),绝不把 PRD、契约、设计全文
复制给每个线程;提示词里必须列出精简包所含章节清单,只写“已精简”不算数;
@@ -49,7 +79,8 @@ full-auto-executor + installation id。质量门禁全部自动且阻塞合并
- G2:架构师和 UI/UX 设计 2 个线程并行,中间加一次联合评审:
架构的状态机和设计的审批界面对得上才能冻结;收敛后你判定契约是否冻结,
冻结物必须齐:API 契约、数据模型、agent 协议、任务状态机、错误 taxonomy、
技术栈表、每端能力矩阵。
技术栈表、每端能力矩阵。(7 项齐全由你人工核对;平台只校验契约版本号
与冻结标志,不校验冻结物清单、不发回归任务。)
- G3:共享核心 1 个线程(含 mock 服务和契约测试);后端线程如有,
在 G3 阶段就参与契约测试,不要等到 G4 才对接。
- G3.5:可行性 spike。核心链路真跑通(agent 工具就是任务能创建、能跑出进度、
@@ -78,6 +109,9 @@ full-auto-executor + installation id。质量门禁全部自动且阻塞合并
(问题清单、热修复记录、回滚触发是否命中)+ v1.1 交接清单齐全,
两项缺一不进 G8;G8 的完成不是下一版已发布,是下一轮 G0-G6 的输入
已冻结齐全。
(阶段顺序是流程编排;平台协调员任务建出后各分工并行执行,不按 G1–G8
分阶段放行——需要分阶段时,分多次建协调员任务,上一阶段的协调员终态
后再建下一阶段。)
## 你的合并规则(评审与执行拆分)
@@ -108,6 +142,11 @@ full-auto-executor + installation id。质量门禁全部自动且阻塞合并
- 决策记录落盘 `docs/decisions.md`,由你维护,只追加不改写历史;
一条决策写清日期、议题、决定、理由、影响端;推翻旧决策追加新条
写明“取代 D-n”,绝不改旧条原文、不删旧条。
- 平台协调员任务暂不支持后续指令:给进行中的协调员任务发后续消息
会被入口直接拒绝(409),因为平台循环内没有消费点,写了也等于没发。
替代方案:改分工请取消协调员任务后重建一个新的;给某个进行中分工
追加指令请直接发给该子任务;已终态的协调员不要走复活,重新建一个
协调员任务,或对相关子任务复活后重跑。
## 跨任务产物传递(协调员)
@@ -127,7 +166,12 @@ full-auto-executor + installation id。质量门禁全部自动且阻塞合并
- 有进度的中断任务:断点续跑。
- 从未开始的任务:全新重跑,不直接判死。
- 单个客户端失败:只重开它,不重开全部。
- 单个客户端失败:只重开它,不重开全部——这是流程口径;平台语义
(`cloud/coordinator.py` 的 `evaluate_outcome`)是:任一非容忍分工
未完成,协调员任务即失败;`on_failure=tolerate` 的分工失败不拖垮
整体,重跑时其 `failed` 子任务不重排、`interrupted` 的仍重排;
未知分工名按失败处理;`merged` 视同完成。`on_failure=tolerate`
须在建协调员任务时声明,事后不可改。
## 你的交付物
@@ -138,6 +182,12 @@ G8 的交付物是 v1.1 交接清单终版与下一轮 G0-G6 输入冻结清单
---
## 独立审计与平台 audit_loop 的区分
- 平台的 `audit_loop` 是协调员任务的自动重排循环(推回包拆分到分工、
子任务重排),不是独立审计线程;独立审计仍须另开线程(附录 11-auditor),
审计 fail 自动推回并阻塞合并/发布,签字人与执行人、协调员批准人、
QA 测试人全不同。两者阻塞关系均为“与”,不可互换。
## GitHub 保证(协调员)
@@ -152,13 +202,15 @@ G8 的交付物是 v1.1 交接清单终版与下一轮 G0-G6 输入冻结清单
测试证据、已知问题、关联待办或 PRD 项;评审意见逐条处理,
未解决的评审意见不能合并。
- 合并门:GitHub Checks 全绿、目录归属检查通过、契约版本是冻结版、
你评审通过、无未解决评审意见,五项缺一不可。
你评审通过、无未解决评审意见,五项缺一不可。协调员批准与契约冻结
版本是每次调合并门禁时提交的证据,平台不持久化;谎报由审计与
PR/Checks 只读核对事后发现。
CI 用只读方式查,例如 GET /tasks/{id}/pr/checks,它绝不重跑检查、
不批准、不合并;无 PR 时它会 409,先补 PR。
- 主分支保护:要求 PR、要求 CI 通过、禁止直接推,这是 GitHub 仓库设置,
不是本平台代码能替你开的。没开保护时记为风险并去开通,不假装已保护。
- 合并门禁:同正文,代码在 cloud/merge_gate.py 与
cloud/merge_gate_hard.py,模式由 CLOUD_MERGE_GATE_MODE 控制,
- 合并门禁:同正文,门禁实现在 cloud/merge_gate_hard.py(默认 hard),
模式由 CLOUD_MERGE_GATE_MODE 控制,
默认 hard:五项全查、缺一 409、状态不变、写 merge_gate_blocked
审计,无证据不放行,unknown 不等于 protected,未保护即使带
显式确认也不放行;record / enforce / off 须显式设置才生效,
+116
View File
@@ -238,6 +238,25 @@ class WorkerCoordinatorTestCase(unittest.TestCase):
self.assertEqual(backend["effort"], "high")
self.assertEqual(backend["status"], "queued")
def test_sync_children_never_carry_coordinator_parts(self):
# 纵深防御(Muse 角色对齐 P1):_coord_sync_children 建出
# 的子任务行 coordinator_parts 恒为 NULL,子任务永为普通任务。
_insert_task(self.db_path, "coord", coordinator_parts=self._parts())
worker = self._worker()
task = worker.get_task("coord")
worker._coord_sync_children(task, task["coordinator_parts"])
conn = sqlite3.connect(self.db_path)
try:
rows = conn.execute(
"SELECT id, coordinator_parts FROM tasks"
" WHERE parent_task_id = ?",
("coord",)).fetchall()
finally:
conn.close()
self.assertEqual(len(rows), 2)
for task_id, parts in rows:
self.assertIsNone(parts, task_id)
def test_sync_requeues_failed_keeps_completed(self):
_insert_task(self.db_path, "coord", coordinator_parts=self._parts())
_insert_task(self.db_path, "c-old", status="failed",
@@ -469,6 +488,103 @@ class ServerCoordinatorTestCase(unittest.TestCase):
self.assertEqual(status, 200)
self.assertEqual(len(data["tasks"]), 3)
def _create_task(self, extra=None):
body = {"project_id": self.project["id"], "prompt": "做点事"}
if extra:
body.update(extra)
return self._req("POST", "/tasks", body, self.owner_key)
def test_create_child_coordinator_under_plain_parent_ok(self):
status, parent = self._create_task()
self.assertEqual(status, 201)
status, child = self._create_coord(
extra={"parent_task_id": parent["id"]})
self.assertEqual(status, 201)
self.assertEqual(child["parent_task_id"], parent["id"])
self.assertEqual(len(child["coordinator_parts"]), 2)
def test_create_coordinator_under_coordinator_rejected(self):
status, coord = self._create_coord()
self.assertEqual(status, 201)
status, body = self._create_coord(
extra={"parent_task_id": coord["id"]})
self.assertEqual(status, 400)
self.assertIn("coordinator task cannot be created under a"
" coordinator task", body["error"])
status, data = self._req(
"GET", "/tasks?project_id=" + self.project["id"],
key=self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(len(data["tasks"]), 1)
def test_create_parent_task_checks(self):
status, _body = self._create_coord(
extra={"parent_task_id": "no-such-task"})
self.assertEqual(status, 404)
status, other = self._req(
"POST", "/projects", {"name": "other"}, self.owner_key)
self.assertEqual(status, 201)
status, parent = self._create_task(
extra={"project_id": other["id"]})
self.assertEqual(status, 201)
status, body = self._create_coord(
extra={"parent_task_id": parent["id"]})
self.assertEqual(status, 400)
status, body = self._create_coord(
extra={"parent_task_id": 123})
self.assertEqual(status, 400)
def test_create_lineage_without_coordinator_ok(self):
status, coord = self._create_coord()
self.assertEqual(status, 201)
status, child = self._create_task(
extra={"parent_task_id": coord["id"]})
self.assertEqual(status, 201)
self.assertEqual(child["parent_task_id"], coord["id"])
def test_create_idempotent_replay_keeps_single_task(self):
status, parent = self._create_task()
self.assertEqual(status, 201)
extra = {"idempotency_key": "idem-parent",
"parent_task_id": parent["id"]}
status, first = self._create_task(extra=extra)
self.assertEqual(status, 201)
status, second = self._create_task(extra=extra)
self.assertEqual(status, 200)
self.assertEqual(second["id"], first["id"])
status, data = self._req(
"GET", "/tasks?project_id=" + self.project["id"],
key=self.owner_key)
self.assertEqual(len(data["tasks"]), 2)
def test_followup_to_coordinator_rejected(self):
status, coord = self._create_coord()
self.assertEqual(status, 201)
status, body = self._req(
"POST", "/tasks/" + coord["id"] + "/followups",
{"body": "改一下分工", "mode": "queued"}, self.owner_key)
self.assertEqual(status, 409)
self.assertIn("coordinator tasks do not accept follow-up",
body["error"])
status, body = self._req(
"POST", "/tasks/" + coord["id"] + "/thread/message",
{"body": "改一下分工", "mode": "queued"}, self.owner_key)
self.assertEqual(status, 409)
self.assertIn("coordinator tasks do not accept follow-up",
body["error"])
def test_followup_to_plain_task_ok(self):
status, task = self._create_task()
self.assertEqual(status, 201)
status, _body = self._req(
"POST", "/tasks/" + task["id"] + "/followups",
{"body": "继续", "mode": "queued"}, self.owner_key)
self.assertEqual(status, 201)
status, body = self._req(
"POST", "/tasks/" + task["id"] + "/thread/message",
{"body": "继续", "mode": "queued"}, self.owner_key)
self.assertEqual(status, 201)
if __name__ == "__main__":
unittest.main()
+2 -1
View File
@@ -185,7 +185,8 @@ class LongtaskAutoResumeTestCase(unittest.TestCase):
captured = {}
def fake_execute(task, kind="dispatch", deadline_seconds=None,
seed_host_dir=None, quota_check=None):
seed_host_dir=None, no_worktree=False,
quota_check=None):
captured["deadline"] = deadline_seconds
return {"status": "completed", "stdout": "", "stderr": ""}
+42
View File
@@ -240,6 +240,48 @@ class TestMigrateNewTables(unittest.TestCase):
"result_text", "error_text", "created_at"):
self.assertIn(col, attempts_inserts[0])
def test_kind_column_backfilled_when_missing(self):
# P2:PG 侧 tasks 尚无 kind 列时,迁移必须先 ALTER 补列
# 再直插,且插入列清单里含 kind;kind 已存在时不重复
# ALTER(与 agent_* 列同款 backfill 惯例)。
with tempfile.TemporaryDirectory() as tmp:
src = os.path.join(tmp, "src.db")
_make_sqlite(src)
fake = FakePgConn(pragma_columns={
"projects": {"repo_url"},
"tasks": {"id", "project_id"},
"task_followups": {"id", "task_id", "project_id",
"body", "mode", "status",
"created_by", "sender_task_id",
"created_at", "updated_at"},
})
result = migrate_sqlite_to_postgres(src, pg_conn=fake)
self.assertEqual(result.get("tasks"), 1, "tasks")
self.assertTrue(
any("tasks" in a and "kind" in a for a in fake.alters),
"未发出 tasks.kind 补列 ALTER:%s" % fake.alters)
# 注:插入列清单取自源 SQLite 表的列,夹具源表无 kind
# 列故清单里无 kind;生产源库(init_db 建表)有 kind,
# 迁移时按列原样搬运(与 agent_* 列同款)。
def test_kind_column_present_skips_alter(self):
with tempfile.TemporaryDirectory() as tmp:
src = os.path.join(tmp, "src.db")
_make_sqlite(src)
fake = FakePgConn(pragma_columns={
"projects": {"repo_url"},
"tasks": {"id", "project_id", "kind"},
"task_followups": {"id", "task_id", "project_id",
"body", "mode", "status",
"created_by", "sender_task_id",
"created_at", "updated_at"},
})
result = migrate_sqlite_to_postgres(src, pg_conn=fake)
self.assertEqual(result.get("tasks"), 1, "tasks")
self.assertFalse(
any("tasks" in a and "kind" in a for a in fake.alters),
"kind 已存在却仍发补列 ALTER:%s" % fake.alters)
def test_sender_column_present_skips_alter(self):
with tempfile.TemporaryDirectory() as tmp:
src = os.path.join(tmp, "src.db")
+75
View File
@@ -28,7 +28,9 @@ import sqlite3
import sys
import tempfile
import time
import traceback
import unittest
from multiprocessing import Barrier, Process, Queue
from pathlib import Path
from types import SimpleNamespace
from unittest import mock
@@ -209,6 +211,18 @@ class SqliteUpsertTestCase(unittest.TestCase):
conn.close()
def _concurrent_init_db_worker(barrier, queue, wid, env):
"""并发启动子进程:与其它进程同时跑 server.init_db。"""
try:
os.environ.update(env)
barrier.wait(timeout=60)
from cloud.server import init_db
init_db(":memory:")
queue.put((wid, "ok", ""))
except Exception:
queue.put((wid, "FAIL", traceback.format_exc()))
class PgInitFixTestCase(unittest.TestCase):
"""真实 PG 测试:无 DSN/驱动时全部 skip。"""
@@ -331,6 +345,67 @@ class PgInitFixTestCase(unittest.TestCase):
finally:
conn.close()
def test_concurrent_init_db_no_unique_violation(self):
"""并发启动回归:4 进程同时 init_db,须全部成功。
覆盖整条启动路径(init_postgres_schema + _ensure_*):
_ensure_* 建的 task_continue_messages / user_gateway_keys /
task_attempts / task_audit_loop_state 不在 POSTGRES_SCHEMA
里,只锁 schema 函数不够;init_db 持会话级 advisory 锁
覆盖全程。空库上跑,未修复时第 1 轮即 3/4 进程报
UniqueViolation(pg_type_typname_nsp_index)。
"""
env = _pg_env()
if env is None:
self.skipTest("未设置可达的 CLOUD_TEST_PG_DSN 或未装驱动,"
"跳过真实 PG 测试")
from cloud.db import PostgresDatabase
db = PostgresDatabase(
host=env["CLOUD_DB_HOST"], port=int(env["CLOUD_DB_PORT"]),
dbname=env["CLOUD_DB_NAME"], user=env["CLOUD_DB_USER"],
password=env["CLOUD_DB_PASSWORD"])
conn = db.connect()
try:
conn.execute("DROP SCHEMA public CASCADE")
conn.execute("CREATE SCHEMA public")
conn.commit()
finally:
conn.close()
nworkers = 4
barrier = Barrier(nworkers)
queue = Queue()
procs = [Process(target=_concurrent_init_db_worker,
args=(barrier, queue, i, env))
for i in range(nworkers)]
try:
for proc in procs:
proc.start()
for proc in procs:
proc.join(timeout=180)
finally:
for proc in procs:
if proc.is_alive():
proc.terminate()
results = [queue.get() for _ in range(nworkers)]
failures = [r for r in results if r[1] == "FAIL"]
self.assertEqual([], failures,
"并发 init_db 有进程失败:%s" % (failures,))
# 初始化完整:schema 表与 _ensure_* 表都在。
conn = db.connect()
try:
rows = db.read_all(
conn,
"SELECT table_name FROM information_schema.tables"
" WHERE table_schema = 'public'",
())
names = {r["table_name"] for r in rows}
for table in ("users", "projects", "tasks",
"task_continue_messages", "user_gateway_keys",
"task_attempts", "task_audit_loop_state"):
self.assertIn(table, names)
finally:
conn.close()
if __name__ == "__main__":
unittest.main()
+80
View File
@@ -17,7 +17,9 @@
import os
import socket
import sys
import traceback
import unittest
from multiprocessing import Barrier, Process, Queue
from pathlib import Path
from unittest import mock
from urllib.parse import unquote, urlparse
@@ -386,3 +388,81 @@ class RealPostgresSchemaTestCase(unittest.TestCase):
except Exception:
pass
conn.close()
def _concurrent_schema_worker(barrier, queue, wid, params):
"""并发初始化子进程:与其它进程同时跑 init_postgres_schema。"""
try:
host, port, dbname, user, password = params
barrier.wait(timeout=60)
db = PostgresDatabase(host=host, port=port, dbname=dbname,
user=user, password=password)
conn = db.connect()
try:
init_postgres_schema(conn)
finally:
conn.close()
queue.put((wid, "ok", ""))
except Exception:
queue.put((wid, "FAIL", traceback.format_exc()))
class RealPostgresConcurrentInitTestCase(unittest.TestCase):
"""真实 PG 并发初始化:无 DSN 或不可达时 skip。
回归:启动竞态(37ba687 与 7983567 两次部署均撞上)——
cloud-server 与 worker 并发 init 时,CREATE TABLE / CREATE
INDEX 的 IF NOT EXISTS 检查与建对象不是原子的,并发建同名
对象会撞 pg_type_typname_nsp_index(建表)或
pg_class_relname_nsp_index(建索引)的 UniqueViolation。
修法是 schema 初始化持 advisory 锁串行化;本用例在空库上
4 进程同时初始化,必须全部成功、表齐全、无一例异常外泄。
"""
def test_concurrent_init_postgres_schema_no_unique_violation(self):
params = _real_pg_params()
if params is None:
self.skipTest("未设置可达的 CLOUD_TEST_PG_DSN,跳过真实 PG 测试")
if not _has_real_driver():
self.skipTest("未装 psycopg 驱动,跳过真实 PG 测试")
host, port, dbname, user, password = params
db = PostgresDatabase(host=host, port=port, dbname=dbname,
user=user, password=password)
conn = db.connect()
try:
conn.execute("DROP SCHEMA public CASCADE")
conn.execute("CREATE SCHEMA public")
conn.commit()
finally:
conn.close()
nworkers = 4
barrier = Barrier(nworkers)
queue = Queue()
procs = [Process(target=_concurrent_schema_worker,
args=(barrier, queue, i, params))
for i in range(nworkers)]
try:
for proc in procs:
proc.start()
for proc in procs:
proc.join(timeout=120)
finally:
for proc in procs:
if proc.is_alive():
proc.terminate()
results = [queue.get() for _ in range(nworkers)]
failures = [r for r in results if r[1] == "FAIL"]
self.assertEqual([], failures,
"并发初始化有进程失败:%s" % (failures,))
conn = db.connect()
try:
rows = db.read_all(
conn,
"SELECT table_name FROM information_schema.tables"
" WHERE table_schema = 'public'",
())
names = {r["table_name"] for r in rows}
for table in SchemaStatementTestCase.EXPECTED_TABLES:
self.assertIn(table, names)
finally:
conn.close()
+730
View File
@@ -0,0 +1,730 @@
"""旁聊任务类型(Muse 角色对齐 P2)单测:创建/列表/执行分流/回写/恢复。
不跑真实模型回合、不联网:服务端侧本地临时端口 + 临时 SQLite;
worker/sandbox 侧注入假 runner 录命令;host.py dispatch 用假
server + 临时 HERE。
"""
import http.client
import json
import os
import sqlite3
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest import mock
try:
import host as host_mod
from cloud.auth import AuthStore
from cloud.marker_tools import (
count_direct_spawn_children,
extract_task_creates,
parent_spawn_generation,
)
from cloud.sandbox import DockerExecutor, LocalExecutor
from cloud.server import create_server, init_db, migrate_tasks_table
from cloud.worker import Worker
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
import host as host_mod
from cloud.auth import AuthStore
from cloud.marker_tools import (
count_direct_spawn_children,
extract_task_creates,
parent_spawn_generation,
)
from cloud.sandbox import DockerExecutor, LocalExecutor
from cloud.server import create_server, init_db, migrate_tasks_table
from cloud.worker import Worker
def _recording(calls):
def fake(cmd):
calls.append(list(cmd))
return SimpleNamespace(returncode=0, stdout="ok", stderr="")
return fake
def _insert_task(db_path, task_id, **cols):
base = {"project_id": "p1", "prompt": "do work", "status": "queued",
"created_by": "tester"}
base.update(cols)
keys = ["id"] + list(base.keys())
vals = [task_id] + list(base.values())
conn = sqlite3.connect(db_path)
try:
conn.execute(
"INSERT INTO tasks(%s) VALUES(%s)"
% (", ".join(keys), ", ".join("?" * len(keys))), vals)
conn.commit()
finally:
conn.close()
def _task_row(db_path, task_id, columns="id, status, kind, executor,"
" parent_task_id, coordinator_part"):
conn = sqlite3.connect(db_path)
try:
row = conn.execute(
"SELECT %s FROM tasks WHERE id = ?" % columns,
(task_id,)).fetchone()
finally:
conn.close()
return row
def _followups(db_path, task_id):
conn = sqlite3.connect(db_path)
try:
rows = conn.execute(
"SELECT id, body, mode, status, sender_task_id"
" FROM task_followups WHERE task_id = ?"
" ORDER BY created_at, id", (task_id,)).fetchall()
finally:
conn.close()
return [{"id": r[0], "body": r[1], "mode": r[2], "status": r[3],
"sender_task_id": r[4]} for r in rows]
def _set_status(db_path, task_id, status):
conn = sqlite3.connect(db_path)
try:
conn.execute("UPDATE tasks SET status = ? WHERE id = ?",
(status, task_id))
conn.commit()
finally:
conn.close()
class _ServerBase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
self.auth = AuthStore(self.db_path)
self.owner_id, self.owner_key = self.auth.create_user("owner")
self.server = create_server(self.db_path, "127.0.0.1", 0)
self.port = self.server.server_address[1]
self.thread = threading.Thread(
target=self.server.serve_forever, daemon=True)
self.thread.start()
status, self.project = self._req(
"POST", "/projects", {"name": "demo"}, self.owner_key)
self.assertEqual(status, 201)
def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=5)
try:
self.auth.conn.close()
except Exception:
pass
self.tmp.cleanup()
def _req(self, method, path, body=None, key=None):
conn = http.client.HTTPConnection(
"127.0.0.1", self.port, timeout=10)
hdrs = {}
payload = None
if body is not None:
payload = json.dumps(body).encode("utf-8")
hdrs["Content-Type"] = "application/json"
if key is not None:
hdrs["X-API-Key"] = key
try:
conn.request(method, path, body=payload, headers=hdrs)
resp = conn.getresponse()
data = resp.read()
finally:
conn.close()
try:
parsed = json.loads(data.decode("utf-8")) if data else None
except ValueError:
parsed = None
return resp.status, parsed
def _create_task(self, extra=None):
body = {"project_id": self.project["id"], "prompt": "做点事"}
if extra:
body.update(extra)
return self._req("POST", "/tasks", body, self.owner_key)
def _create_side(self, parent_id, extra=None):
body = {"project_id": self.project["id"], "prompt": "旁聊一下",
"kind": "side", "parent_task_id": parent_id}
if extra:
body.update(extra)
return self._req("POST", "/tasks", body, self.owner_key)
class ServerSideThreadTestCase(_ServerBase):
def test_create_side_ok_defaults_local(self):
status, parent = self._create_task()
self.assertEqual(status, 201)
status, side = self._create_side(parent["id"])
self.assertEqual(status, 201, side)
self.assertEqual(side["kind"], "side")
self.assertEqual(side["parent_task_id"], parent["id"])
self.assertEqual(side["executor"], "local")
status, fetched = self._req(
"GET", "/tasks/" + side["id"], key=self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(fetched["kind"], "side")
def test_create_side_validation(self):
status, parent = self._create_task()
self.assertEqual(status, 201)
# 缺 parent_task_id
status, body = self._req(
"POST", "/tasks",
{"project_id": self.project["id"], "prompt": "x",
"kind": "side"}, self.owner_key)
self.assertEqual(status, 400)
# 父不存在
status, _body = self._create_side("no-such-parent")
self.assertEqual(status, 404)
# kind 非法值
status, _body = self._create_task(
{"kind": "weird", "parent_task_id": parent["id"]})
self.assertEqual(status, 400)
# 带 coordinator_parts
status, _body = self._create_side(parent["id"], {
"coordinator_parts": [{"name": "a", "prompt": "x"}]})
self.assertEqual(status, 400)
# 带 upload_id / environment_id
status, _body = self._create_side(
parent["id"], {"upload_id": "up-nonexistent"})
self.assertEqual(status, 400)
status, _body = self._create_side(
parent["id"], {"environment_id": "env-nonexistent"})
self.assertEqual(status, 400)
def test_create_side_cross_project_rejected(self):
status, parent = self._create_task()
self.assertEqual(status, 201)
status, other_project = self._req(
"POST", "/projects", {"name": "other"}, self.owner_key)
self.assertEqual(status, 201)
status, other_task = self._req(
"POST", "/tasks",
{"project_id": other_project["id"], "prompt": "别处的事"},
self.owner_key)
self.assertEqual(status, 201)
status, _body = self._create_side(other_task["id"])
self.assertEqual(status, 400)
def test_create_side_under_coordinator_rejected(self):
status, coord = self._req(
"POST", "/tasks",
{"project_id": self.project["id"], "prompt": "协调干活",
"coordinator_parts": [{"name": "a", "prompt": "做 a"}]},
self.owner_key)
self.assertEqual(status, 201)
status, body = self._create_side(coord["id"])
self.assertEqual(status, 400)
self.assertIn("coordinator", body["error"])
def test_create_side_explicit_docker_kept(self):
status, parent = self._create_task()
self.assertEqual(status, 201)
status, side = self._create_side(
parent["id"], {"executor": "docker"})
self.assertEqual(status, 201, side)
self.assertEqual(side["executor"], "docker")
self.assertEqual(side["kind"], "side")
def test_create_main_defaults(self):
status, task = self._create_task()
self.assertEqual(status, 201)
self.assertEqual(task["kind"], "main")
status, task2 = self._create_task({"kind": "main"})
self.assertEqual(status, 201)
self.assertEqual(task2["kind"], "main")
def test_list_kind_filter(self):
status, parent = self._create_task()
self.assertEqual(status, 201)
_s, side1 = self._create_side(parent["id"])
_s, side2 = self._create_side(parent["id"])
_s, main_child = self._create_task(
{"parent_task_id": parent["id"]})
self.assertEqual(main_child["kind"], "main")
pid = self.project["id"]
status, data = self._req(
"GET", "/tasks?project_id=" + pid +
"&parent_task_id=" + parent["id"] + "&kind=side",
key=self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(
sorted(t["id"] for t in data["tasks"]),
sorted([side1["id"], side2["id"]]))
status, data = self._req(
"GET", "/tasks?project_id=" + pid +
"&parent_task_id=" + parent["id"], key=self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(len(data["tasks"]), 3)
status, data = self._req(
"GET", "/tasks?project_id=" + pid + "&kind=side",
key=self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(
sorted(t["id"] for t in data["tasks"]),
sorted([side1["id"], side2["id"]]))
status, data = self._req(
"GET", "/tasks?project_id=" + pid + "&kind=main",
key=self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(
sorted(t["id"] for t in data["tasks"]),
sorted([parent["id"], main_child["id"]]))
status, _body = self._req(
"GET", "/tasks?project_id=" + pid + "&kind=bogus",
key=self.owner_key)
self.assertEqual(status, 400)
def test_side_files_tree_worktree_404(self):
status, parent = self._create_task()
self.assertEqual(status, 201)
status, side = self._create_side(parent["id"])
self.assertEqual(status, 201)
for suffix in ("/files", "/tree", "/worktree"):
status, body = self._req(
"GET", "/tasks/" + side["id"] + suffix,
key=self.owner_key)
self.assertEqual(status, 404, suffix)
self.assertIn("no local worktree", body["error"])
def test_side_fork_merge_pr_409(self):
status, parent = self._create_task()
self.assertEqual(status, 201)
status, side = self._create_side(parent["id"])
self.assertEqual(status, 201)
status, body = self._req(
"POST", "/tasks/" + side["id"] + "/fork",
{"message": "分一个"}, self.owner_key)
self.assertEqual(status, 409)
self.assertIn("side thread", body["error"])
status, body = self._req(
"POST", "/tasks/" + side["id"] + "/merge", {},
self.owner_key)
self.assertEqual(status, 409)
self.assertIn("side thread", body["error"])
status, body = self._req(
"POST", "/tasks/" + side["id"] + "/pr", {},
self.owner_key)
self.assertEqual(status, 409)
self.assertIn("side thread", body["error"])
def test_side_continue_and_followups(self):
status, parent = self._create_task()
self.assertEqual(status, 201)
status, side = self._create_side(parent["id"])
self.assertEqual(status, 201)
_set_status(self.db_path, side["id"], "completed")
status, task = self._req(
"POST", "/tasks/" + side["id"] + "/continue",
{"message": "再聊聊"}, self.owner_key)
self.assertEqual(status, 200, task)
self.assertEqual(task["status"], "queued")
self.assertEqual(task["resume_message"], "再聊聊")
status, _body = self._req(
"POST", "/tasks/" + side["id"] + "/followups",
{"body": "继续", "mode": "queued"}, self.owner_key)
self.assertEqual(status, 201)
def test_side_writeback_visible_on_terminal_parent(self):
# 父终态时回写只作记录:followups 可见,父不复活。
status, parent = self._create_task()
self.assertEqual(status, 201)
status, side = self._create_side(parent["id"])
self.assertEqual(status, 201)
_set_status(self.db_path, parent["id"], "completed")
conn = sqlite3.connect(self.db_path)
try:
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', ?, ?, ?, ?)",
("fu-1", parent["id"], self.project["id"],
"[旁聊结论] 查到了", "tester", side["id"],
time.time(), time.time()))
conn.commit()
finally:
conn.close()
status, data = self._req(
"GET", "/tasks/" + parent["id"] + "/followups",
key=self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(len(data["followups"]), 1)
self.assertEqual(data["followups"][0]["sender_task_id"],
side["id"])
status, fetched = self._req(
"GET", "/tasks/" + parent["id"], key=self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(fetched["status"], "completed")
class _WorkerBase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
init_db(self.db_path)
self.env = {"XCM_API_KEY": "test-placeholder"}
def tearDown(self):
self.tmp.cleanup()
def _worker(self, calls):
return Worker(self.db_path, "/fake/host.py",
runner=_recording(calls), env=self.env)
class WorkerSideThreadTestCase(_WorkerBase):
def test_run_one_side_no_repo_no_worktree_and_writeback(self):
_insert_task(self.db_path, "parent1", executor="local")
_insert_task(self.db_path, "side1", kind="side",
parent_task_id="parent1", executor="local")
calls = []
worker = self._worker(calls)
result = worker.run_one(worker.get_task("side1"))
self.assertEqual(result["status"], "completed")
dispatch_cmds = [c for c in calls if "dispatch" in c]
self.assertEqual(len(dispatch_cmds), 1)
cmd = dispatch_cmds[0]
self.assertIn("--no-worktree", cmd)
self.assertNotIn("--repo", cmd)
self.assertNotIn("--seed-dir", cmd)
row = _task_row(self.db_path, "side1")
self.assertEqual(row[1], "completed")
fus = _followups(self.db_path, "parent1")
self.assertEqual(len(fus), 1)
self.assertEqual(fus[0]["sender_task_id"], "side1")
self.assertEqual(fus[0]["mode"], "queued")
self.assertEqual(fus[0]["status"], "pending")
self.assertTrue(fus[0]["body"].startswith("[旁聊结论]"))
def test_run_attempt_side_with_repo_forces_plan(self):
# worker 层直接调 _run_attempt:有 repo 的旁聊强制 plan=1,
# argv 同时含 --no-worktree 与 --sandbox read-only。
calls = []
worker = self._worker(calls)
task = {"id": "side-repo", "prompt": "聊聊 repo",
"project_id": "p1", "created_by": "tester",
"kind": "side", "executor": "local",
"repo": "/tmp/fake-repo",
"_gateway_provider": None, "_gateway_env": {}}
result, error = worker._run_attempt(task)
self.assertIsNone(error)
self.assertEqual(result["status"], "completed")
dispatch_cmds = [c for c in calls if "dispatch" in c]
self.assertEqual(len(dispatch_cmds), 1)
cmd = dispatch_cmds[0]
self.assertIn("--no-worktree", cmd)
self.assertIn("--sandbox", cmd)
self.assertEqual(cmd[cmd.index("--sandbox") + 1], "read-only")
self.assertIn("--repo", cmd)
self.assertEqual(cmd[cmd.index("--repo") + 1], "/tmp/fake-repo")
def test_run_attempt_main_task_untouched(self):
calls = []
worker = self._worker(calls)
task = {"id": "main1", "prompt": "做事",
"project_id": "p1", "created_by": "tester",
"executor": "local",
"_gateway_provider": None, "_gateway_env": {}}
result, error = worker._run_attempt(task)
self.assertIsNone(error)
self.assertEqual(result["status"], "completed")
dispatch_cmds = [c for c in calls if "dispatch" in c]
self.assertEqual(len(dispatch_cmds), 1)
self.assertNotIn("--no-worktree", dispatch_cmds[0])
self.assertNotIn("--sandbox", dispatch_cmds[0])
def test_writeback_direct_shapes(self):
_insert_task(self.db_path, "parent1", executor="local")
worker = Worker(self.db_path, "/fake/host.py", env=self.env)
task = {"id": "side1", "project_id": "p1",
"created_by": "tester", "kind": "side",
"parent_task_id": "parent1"}
self.assertTrue(worker._writeback_side_thread(
task, "completed", "结论文本"))
self.assertTrue(worker._writeback_side_thread(
task, "failed", "出错了"))
self.assertTrue(worker._writeback_side_thread(
task, "interrupted", ""))
fus = _followups(self.db_path, "parent1")
self.assertEqual(len(fus), 3)
self.assertTrue(fus[0]["body"].startswith("[旁聊结论]"))
self.assertTrue(fus[1]["body"].startswith("[旁聊失败]"))
self.assertTrue(fus[2]["body"].startswith("[旁聊中断]"))
# 超长正文截断到 2000 字 + 截断标记。
self.assertTrue(worker._writeback_side_thread(
task, "completed", "x" * 3000))
fus = _followups(self.db_path, "parent1")
self.assertLessEqual(len(fus[3]["body"]), 2010)
self.assertIn("已截断", fus[3]["body"])
# 无父任务不写。
orphan = dict(task, parent_task_id=None)
self.assertFalse(worker._writeback_side_thread(
orphan, "completed", "x"))
def test_parent_running_drain_consumes_writeback(self):
_insert_task(self.db_path, "parent1", executor="local")
_insert_task(self.db_path, "side1", kind="side",
parent_task_id="parent1", executor="local")
worker = Worker(self.db_path, "/fake/host.py", env=self.env)
self.assertTrue(worker._writeback_side_thread(
{"id": "side1", "project_id": "p1", "created_by": "tester",
"kind": "side", "parent_task_id": "parent1"},
"completed", "结论文本"))
calls = []
worker = self._worker(calls)
result = worker.run_one(worker.get_task("parent1"))
self.assertEqual(result["status"], "completed")
# 首轮 + 排空一轮 = 两次 dispatch。
dispatch_cmds = [c for c in calls if "dispatch" in c]
self.assertEqual(len(dispatch_cmds), 2)
fus = _followups(self.db_path, "parent1")
self.assertEqual(len(fus), 1)
self.assertEqual(fus[0]["status"], "done")
class MarkerSideThreadTestCase(_WorkerBase):
def _deliver(self, parent_id, creates):
worker = Worker(self.db_path, "/fake/host.py", env=self.env)
worker._deliver_marker_tools(worker.get_task(parent_id), {
"status": "completed", "task_creates": creates,
"task_polls": [], "wait_threads": [], "task_cancels": [],
"task_retries": []})
def test_extract_kind(self):
text = (
"```task-create\n"
"{\"prompt\": \"做子事\", \"kind\": \"side\"}\n```\n"
"```task-create\n"
"{\"prompt\": \"做子事\", \"kind\": \"weird\"}\n```\n"
"```task-create\n{\"prompt\": \"做子事\"}\n```\n")
creates = extract_task_creates(text)
self.assertEqual(len(creates), 2)
self.assertEqual(creates[0]["kind"], "side")
self.assertIsNone(creates[1]["kind"])
def test_marker_child_inherits_side(self):
_insert_task(self.db_path, "side-parent", kind="side",
executor="local")
self._deliver("side-parent", [{
"name": "子旁聊", "prompt": "继续聊",
"model": None, "effort": None, "executor": None,
"kind": None}])
conn = sqlite3.connect(self.db_path)
try:
child = conn.execute(
"SELECT kind, executor FROM tasks"
" WHERE parent_task_id = ?",
("side-parent",)).fetchone()
finally:
conn.close()
self.assertIsNotNone(child)
self.assertEqual(child[0], "side")
self.assertEqual(child[1], "local")
def test_marker_child_explicit_kind_wins(self):
_insert_task(self.db_path, "side-parent", kind="side",
executor="local")
self._deliver("side-parent", [{
"name": "转主聊", "prompt": "正经做",
"model": None, "effort": None, "executor": None,
"kind": "main"}])
conn = sqlite3.connect(self.db_path)
try:
child = conn.execute(
"SELECT kind FROM tasks WHERE parent_task_id = ?",
("side-parent",)).fetchone()
finally:
conn.close()
self.assertIsNotNone(child)
self.assertEqual(child[0], "main")
def test_marker_counts_exclude_side(self):
_insert_task(self.db_path, "main-parent", executor="local")
_insert_task(self.db_path, "side-kid", kind="side",
parent_task_id="main-parent", executor="local")
_insert_task(self.db_path, "main-kid",
parent_task_id="main-parent", executor="local")
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
try:
self.assertEqual(
count_direct_spawn_children(conn, "main-parent"), 1)
self.assertEqual(
parent_spawn_generation(conn, "side-kid"), 0)
self.assertEqual(
parent_spawn_generation(conn, "main-kid"), 1)
finally:
conn.close()
class MigrateSideThreadTestCase(unittest.TestCase):
def test_migrate_adds_kind_idempotent(self):
with tempfile.TemporaryDirectory() as tmp:
db_path = os.path.join(tmp, "old.db")
conn = sqlite3.connect(db_path)
try:
conn.execute(
"CREATE TABLE tasks(id TEXT PRIMARY KEY,"
" project_id TEXT, prompt TEXT, status TEXT)")
conn.commit()
migrate_tasks_table(conn)
cols = {r[1] for r in conn.execute(
"PRAGMA table_info(tasks)").fetchall()}
self.assertIn("kind", cols)
# 第二次是 no-op,不炸、不重复。
migrate_tasks_table(conn)
cols2 = {r[1] for r in conn.execute(
"PRAGMA table_info(tasks)").fetchall()}
self.assertEqual(cols, cols2)
finally:
conn.close()
class SandboxSideThreadTestCase(unittest.TestCase):
def test_local_dispatch_no_worktree_flag(self):
calls = []
ex = LocalExecutor("/fake/host.py", runner=_recording(calls))
ex.dispatch_task({"id": "t1", "prompt": "hi"},
no_worktree=True)
self.assertIn("--no-worktree", calls[0])
calls.clear()
ex.dispatch_task({"id": "t1", "prompt": "hi"})
self.assertNotIn("--no-worktree", calls[0])
def test_local_goal_no_worktree_flag(self):
calls = []
ex = LocalExecutor("/fake/host.py", runner=_recording(calls))
ex.goal_task({"id": "t1", "prompt": "目标"}, no_worktree=True)
self.assertIn("--no-worktree", calls[0])
self.assertIn("goal", calls[0])
def test_local_plan_passthrough_readonly(self):
calls = []
ex = LocalExecutor("/fake/host.py", runner=_recording(calls))
ex.dispatch_task({"id": "t1", "prompt": "hi", "plan": 1})
self.assertIn("--sandbox", calls[0])
self.assertEqual(calls[0][calls[0].index("--sandbox") + 1],
"read-only")
calls.clear()
ex.dispatch_task({"id": "t1", "prompt": "hi"})
self.assertNotIn("--sandbox", calls[0])
def test_local_popen_no_worktree_flag(self):
calls = []
class FakePopen(object):
def __init__(self, cmd, **kwargs):
calls.append(list(cmd))
self.returncode = 0
def poll(self):
return 0
def communicate(self, timeout=None):
return "", ""
ex = LocalExecutor("/fake/host.py", runner=_recording([]))
import cloud.sandbox as sandbox_mod
with mock.patch.object(sandbox_mod.subprocess, "Popen",
FakePopen):
ex.popen_task({"id": "t1", "prompt": "hi"},
kind="dispatch", no_worktree=True)
ex.popen_task({"id": "t1", "prompt": "目标"},
kind="goal", no_worktree=True)
dispatch_cmds = [c for c in calls if "dispatch" in c]
goal_cmds = [c for c in calls if "goal" in c]
self.assertEqual(len(dispatch_cmds), 1)
self.assertEqual(len(goal_cmds), 1)
self.assertIn("--no-worktree", dispatch_cmds[0])
self.assertIn("--no-worktree", goal_cmds[0])
def test_docker_dispatch_no_worktree_flag(self):
calls = []
ex = DockerExecutor(
"/fake/host.py", runner=_recording(calls),
env={"XCM_API_KEY": "placeholder-not-a-real-key"})
with mock.patch("cloud.sandbox.shutil.which",
return_value="/usr/bin/docker"):
ex.dispatch_task({"id": "t1", "prompt": "hi"},
no_worktree=True)
self.assertIn("--no-worktree", calls[0])
calls.clear()
with mock.patch("cloud.sandbox.shutil.which",
return_value="/usr/bin/docker"):
ex.dispatch_task({"id": "t1", "prompt": "hi"})
self.assertNotIn("--no-worktree", calls[0])
def test_docker_goal_no_worktree_flag(self):
calls = []
ex = DockerExecutor(
"/fake/host.py", runner=_recording(calls),
env={"XCM_API_KEY": "placeholder-not-a-real-key"})
with mock.patch("cloud.sandbox.shutil.which",
return_value="/usr/bin/docker"):
ex.goal_task({"id": "t1", "prompt": "目标"},
no_worktree=True)
self.assertIn("--no-worktree", calls[0])
class _FakeHostServer(object):
def __init__(self):
self.created = []
def create_thread(self, cwd, model=None, sandbox="workspace-write",
approval_policy="never", instructions=None):
self.created.append((cwd, sandbox))
return "thread-1"
class HostSideThreadTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self._old_here = host_mod.HERE
host_mod.HERE = self.tmp.name
def tearDown(self):
host_mod.HERE = self._old_here
self.tmp.cleanup()
def test_dispatch_no_worktree_no_repo_uses_scratch(self):
srv = _FakeHostServer()
task = host_mod.dispatch(
srv, {"tasks": {}}, "side-abc", "聊聊", None, None, None,
None, "never", use_worktree=False)
expected = os.path.join(self.tmp.name, "work", "side-threads",
"side-abc")
self.assertEqual(task["cwd"], expected)
self.assertTrue(os.path.isdir(expected))
self.assertFalse(os.path.exists(os.path.join(expected, ".git")))
self.assertIsNone(task["branch"])
self.assertEqual(srv.created[0][0], expected)
# 没有建 host-worktrees 目录。
self.assertFalse(os.path.exists(
os.path.join(self.tmp.name, "work", "host-worktrees")))
def test_dispatch_no_worktree_with_repo_uses_repo(self):
srv = _FakeHostServer()
task = host_mod.dispatch(
srv, {"tasks": {}}, "side-def", "聊聊", "/tmp/some-repo",
None, None, None, "never", use_worktree=False)
self.assertEqual(task["cwd"], "/tmp/some-repo")
self.assertIsNone(task["branch"])
self.assertFalse(os.path.exists(
os.path.join(self.tmp.name, "work", "side-threads")))
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -12,7 +12,7 @@ seeded file -- a dropped middle chapter (01..07) would pass CI silently.
The bidirectional assertion previously discussed on the #209 track is
unmerged, so main does not have it; this file supplies it on the main
base (skill text v0.2, 9 appendix chapters 00..08;
integrated text is v0.5, 11 appendix chapters 00..10).
integrated text is v0.5, 12 appendix chapters 00..11).
What "bidirectional" means here, concretely (all derived from SKILL.md
itself, no new semantics invented):
+93 -2
View File
@@ -6,6 +6,7 @@
执行器 per-call 挂载/网络覆盖、网络创建失败不降级。
"""
import ipaddress
import os
import sys
import tempfile
@@ -15,12 +16,12 @@ from types import SimpleNamespace
from unittest import mock
try:
from cloud.sandbox import DockerExecutor
from cloud.sandbox import DockerExecutor, tenant_subnet_candidates
from cloud.server import init_db
from cloud.worker import Worker
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.sandbox import DockerExecutor
from cloud.sandbox import DockerExecutor, tenant_subnet_candidates
from cloud.server import init_db
from cloud.worker import Worker
@@ -118,6 +119,34 @@ class TenantDirsTestCase(unittest.TestCase):
worker._tenant_dirs("projA")
class TenantSubnetCandidatesTestCase(unittest.TestCase):
def test_candidates_inside_allowlist_distinct_and_24(self):
allow = ipaddress.IPv4Network("172.16.0.0/12")
seen = list(tenant_subnet_candidates("codex-p"))
self.assertEqual(len(seen), 32)
self.assertEqual(len(set(seen)), 32)
for item in seen:
net = ipaddress.IPv4Network(item)
self.assertEqual(net.prefixlen, 24)
self.assertTrue(net.subnet_of(allow))
def test_candidates_deterministic_and_name_dependent(self):
first = list(tenant_subnet_candidates("codex-a"))
self.assertEqual(first, list(tenant_subnet_candidates("codex-a")))
# sha256("codex-a") 首字节 59;顺延一格是 60。
self.assertEqual(first[0], "172.16.59.0/24")
self.assertEqual(first[1], "172.16.60.0/24")
self.assertNotEqual(
first[0], list(tenant_subnet_candidates("codex-b"))[0])
def test_candidates_wrap_around_last_slot(self):
# sha256("codex-zz") 首字节 250:槽位到 255 后绕回 0。
seen = list(tenant_subnet_candidates("codex-zz"))
self.assertEqual(seen[0], "172.16.250.0/24")
self.assertEqual(seen[5], "172.16.255.0/24")
self.assertEqual(seen[6], "172.16.0.0/24")
class DockerTenantCommandTestCase(unittest.TestCase):
def test_per_call_mounts_network_and_drop_caps(self):
seen = []
@@ -165,6 +194,68 @@ class DockerTenantCommandTestCase(unittest.TestCase):
env={"XCM_API_KEY": "test-placeholder"})
self.assertFalse(ex.ensure_network("codex-p"))
def test_ensure_network_creates_with_pinned_subnet(self):
seen = []
with mock.patch("cloud.sandbox.shutil.which",
return_value="/usr/bin/docker"):
ex = DockerExecutor(
"/fake/host.py",
runner=_ok_runner_factory(seen),
env={"XCM_API_KEY": "test-placeholder"})
self.assertTrue(ex.ensure_network("codex-p"))
self.assertEqual(len(seen), 1)
# sha256("codex-p") 首字节 138:首候选 172.16.138.0/24。
self.assertEqual(seen[0], ["docker", "network", "create",
"--subnet", "172.16.138.0/24",
"codex-p"])
def test_ensure_network_overlap_probes_next_subnet(self):
seen = []
def overlap_then_ok(cmd, timeout=None):
seen.append(list(cmd))
if len(seen) == 1:
return SimpleNamespace(
returncode=1, stdout="",
stderr="Error response from daemon: Pool overlaps "
"with other one on this address space")
return SimpleNamespace(returncode=0, stdout="", stderr="")
with mock.patch("cloud.sandbox.shutil.which",
return_value="/usr/bin/docker"):
ex = DockerExecutor("/fake/host.py", runner=overlap_then_ok,
env={"XCM_API_KEY": "test-placeholder"})
self.assertTrue(ex.ensure_network("codex-p"))
self.assertEqual(len(seen), 2)
self.assertEqual(seen[0][4], "172.16.138.0/24")
self.assertEqual(seen[1][4], "172.16.139.0/24")
self.assertEqual(seen[1][5], "codex-p")
def test_ensure_network_overlap_exhausted_is_false(self):
seen = []
def always_overlap(cmd, timeout=None):
seen.append(list(cmd))
return SimpleNamespace(
returncode=1, stdout="",
stderr="Error response from daemon: Pool overlaps "
"with other one on this address space")
with mock.patch("cloud.sandbox.shutil.which",
return_value="/usr/bin/docker"):
ex = DockerExecutor("/fake/host.py", runner=always_overlap,
env={"XCM_API_KEY": "test-placeholder"})
self.assertFalse(ex.ensure_network("codex-p"))
self.assertEqual(len(seen), 32)
def test_ensure_network_empty_name_is_false(self):
def must_not_run(cmd, timeout=None):
raise AssertionError("empty name must not call docker")
with mock.patch("cloud.sandbox.shutil.which",
return_value="/usr/bin/docker"):
ex = DockerExecutor("/fake/host.py", runner=must_not_run,
env={"XCM_API_KEY": "test-placeholder"})
self.assertFalse(ex.ensure_network(""))
self.assertFalse(ex.ensure_network(None))
if __name__ == "__main__":
unittest.main()
+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)
+1
View File
@@ -56,6 +56,7 @@
| B16 | worker / 监控 / 模型(`GET /workers`、`/workers/heartbeat`、`/metrics`、`/metrics-report`、`/audit-scorecard`、`/routing-shadow`、`/models`、`/relay/models`) | `model/list`、`modelProvider/capabilities/read` 覆盖模型面;其余无 | 部分映射:模型列表有对应;worker 池、指标、评分卡、路由影子 **原生无对应表面** |
| B17 | 待办(5 个端点,`/projects/{id}/todos`) | 无 | 原生无对应表面 |
| B18 | 模型总览与中转模型列表(`GET /models`、`GET /relay/models`) | `model/list`(C 实测仅一条 `gpt-6.1-sol`) | 部分映射:单模型列表有对应;中转站多模型下拉无原生面(旧控制台用 `/relay/models` 做模型下拉,新 UI 用 `model/list`) |
| B19 | 旁聊会话列表(`kind='side'` 任务:`POST /tasks` 带 `kind` + `parent_task_id`、`GET /tasks?kind=side`、`GET /tasks?parent_task_id=&kind=side`) | 无。原生 UI 只有线程列表,无“主聊 + 旁聊”分组、无 kind 筛选、无“开旁聊”入口 | 原生无对应表面(旁聊会话列表仅 `/console-classic`) |
## §3 旧 console.html 的去留决定
+12
View File
@@ -2588,6 +2588,13 @@ def dispatch(server, state, name, prompt, repo, model, effort, sandbox,
branch = None
if use_worktree and repo:
cwd, branch = make_worktree(repo, name)
if not use_worktree and not repo:
# 旁聊无 repo:create_thread 不接受 cwd=None
# (os.path.abspath(None) 抛错),以 per-task scratch
# 目录为 cwd(普通目录,非 git worktree,不建分支;
# host 任务名即任务 id 的安全化形式,目录按任务隔离)。
cwd = os.path.join(HERE, "work", "side-threads", name)
os.makedirs(cwd, exist_ok=True)
if seed_dir:
# 附件只能进 worktree:直接以 repo 为 cwd 时拷进去等于污染
# 仓库,必须 loud fail,绝不静默放行。
@@ -3259,6 +3266,10 @@ def main():
p.add_argument("--repo", default=None)
p.add_argument("--seed-dir", default=None,
help="附件暂存目录:派发时拷进 worktree 再跑 turn")
p.add_argument("--no-worktree", action="store_true",
help="不建 worktree/分支:无 repo 时以 per-task"
" scratch 目录为 cwd,有 repo 时以 repo 为"
" cwd(旁聊默认,见 dispatch 同款)")
p.add_argument("--verify", default=None,
help="验收命令;不传时走模型自决:模型在回合末尾"
"单独一行输出“目标已达成”即达标")
@@ -4208,6 +4219,7 @@ def main():
task = dispatch(srv, state, args.name, args.task, args.repo,
args.model, args.effort, args.sandbox,
args.approval_policy,
use_worktree=not args.no_worktree,
seed_dir=args.seed_dir)
save_state(args.state, state)
# 新开循环的 spawn goal 是权威的:上一轮同名循环