feat: harden code queue runtime UX
- add queue merge controls and no-cache overview loading\n- improve runtime probes, judge retries, and task trace rendering\n- extend CLI/E2E/docs coverage for code queue and user services
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -351,7 +351,7 @@ h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.08em; }
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.overview-grid .panel:nth-child(n+3), .dispatch-grid .panel:first-child, .scheduled-task-page .panel:first-child, .scheduled-task-page .panel:nth-child(3), .topology-grid .panel:nth-child(3) { grid-column: 1 / -1; }
|
||||
.overview-grid .panel:nth-child(n+3), .dispatch-grid .panel:first-child, .scheduled-task-page .panel:first-child, .scheduled-task-page .panel:nth-child(2), .scheduled-task-page .panel:nth-child(3), .topology-grid .panel:nth-child(3) { grid-column: 1 / -1; }
|
||||
|
||||
.panel {
|
||||
min-width: 0;
|
||||
@@ -381,8 +381,8 @@ h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.08em; }
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
min-height: 76px;
|
||||
padding: 10px;
|
||||
min-height: 52px;
|
||||
padding: 7px;
|
||||
border: 1px solid var(--line-soft);
|
||||
background: var(--panel-2);
|
||||
}
|
||||
@@ -405,9 +405,9 @@ h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.08em; }
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
.metric-value {
|
||||
margin-top: 8px;
|
||||
margin-top: 4px;
|
||||
color: var(--text);
|
||||
font-size: 23px;
|
||||
font-size: 16px;
|
||||
font-weight: 760;
|
||||
}
|
||||
.metric-hint { margin-top: 3px; color: var(--muted); font-size: 11px; }
|
||||
@@ -1330,10 +1330,12 @@ td { color: var(--text); }
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.label-card { padding: 8px; }
|
||||
.label-card span { display: block; color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.1em; }
|
||||
.label-card strong { display: block; margin: 5px 0; }
|
||||
.label-card { padding: 8px; min-width: 0; }
|
||||
.label-card span { display: block; color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.1em; overflow-wrap: anywhere; }
|
||||
.label-card strong { display: block; margin: 5px 0; overflow-wrap: anywhere; }
|
||||
.label-card code { overflow-wrap: anywhere; white-space: normal; }
|
||||
|
||||
.dispatch-form {
|
||||
display: grid;
|
||||
@@ -2349,7 +2351,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
grid-template-areas:
|
||||
"queue-select queue-select"
|
||||
"rename rename"
|
||||
". create";
|
||||
"merge create";
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
@@ -2358,6 +2360,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
grid-area: queue-select;
|
||||
}
|
||||
.codex-rename-queue-btn,
|
||||
.codex-merge-queue-btn,
|
||||
.codex-create-queue-btn {
|
||||
min-height: 32px;
|
||||
white-space: nowrap;
|
||||
@@ -2370,6 +2373,9 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
.codex-create-queue-btn {
|
||||
grid-area: create;
|
||||
}
|
||||
.codex-merge-queue-btn {
|
||||
grid-area: merge;
|
||||
}
|
||||
.codex-reference-field {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
@@ -5365,9 +5371,10 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
grid-template-areas:
|
||||
"queue-select"
|
||||
"rename"
|
||||
"merge"
|
||||
"create";
|
||||
}
|
||||
.codex-rename-queue-btn, .codex-create-queue-btn { width: 100%; }
|
||||
.codex-rename-queue-btn, .codex-merge-queue-btn, .codex-create-queue-btn { width: 100%; }
|
||||
.codex-session-title-toggle { min-height: 40px; padding: 9px 15px; font-size: 14px; }
|
||||
.codex-attempt-cycle-head { align-items: flex-start; }
|
||||
.codex-attempt-cycle-head code {
|
||||
|
||||
@@ -378,6 +378,38 @@ export function BaiduNetdiskPage({ microservices, onRaw, apiBaseUrl = "/api" }:
|
||||
h(MetricCard, { label: "Quota", value: fmtBytes(quota.used), hint: quota.total ? `${quota.usedPercent || 0}% / ${fmtBytes(quota.total)}` : "授权后刷新" }),
|
||||
h(MetricCard, { label: "Transfers", value: numberText(jobs.length), hint: `running ${state.transfers?.counts?.running || 0} / failed ${state.transfers?.counts?.failed || 0}` }),
|
||||
),
|
||||
h(Panel, { title: "文件浏览器", eyebrow: currentDir, className: "baidu-files-panel", loading: state.loading,
|
||||
actions: h("div", { className: "panel-actions inline-actions" },
|
||||
h("button", { type: "button", className: "ghost-btn", onClick: () => { const parent = pathParent(currentDir, appRoot); setCurrentDir(parent); void loadFiles(parent); }, disabled: !loggedIn || currentDir === appRoot }, "上级"),
|
||||
h("button", { type: "button", className: "ghost-btn", onClick: () => loadFiles(currentDir), disabled: !loggedIn }, "刷新文件"),
|
||||
h(RawButton, { title: "Baidu Files", data: state.files, onOpen: onRaw, testId: "raw-baidu-files" }),
|
||||
),
|
||||
},
|
||||
h("form", { className: "baidu-pathbar", onSubmit: (event: any) => { event.preventDefault(); void loadFiles(currentDir); } },
|
||||
h("input", { value: currentDir, onChange: (event: any) => setCurrentDir(event.target.value), disabled: !loggedIn }),
|
||||
h("button", { type: "submit", className: "ghost-btn", disabled: !loggedIn }, "打开路径"),
|
||||
),
|
||||
h("form", { className: "baidu-pathbar", onSubmit: createFolder },
|
||||
h("input", { value: folderName, onChange: (event: any) => setFolderName(event.target.value), placeholder: "新文件夹名称", disabled: !loggedIn }),
|
||||
h("button", { type: "submit", className: "primary-btn", disabled: !loggedIn || !folderName.trim() }, "新建文件夹"),
|
||||
),
|
||||
!loggedIn ? h(EmptyState, { title: "等待授权", text: "登录后通过 /api/files 读取工作目录文件列表" }) : files.length === 0 ? h(EmptyState, { title: "目录为空", text: "可以从 staging 目录上传文件或新建文件夹" }) :
|
||||
h("div", { className: "table-wrap", "data-testid": "baidu-netdisk-file-table" }, h("table", null,
|
||||
h("thead", null, h("tr", null, h("th", null, "名称"), h("th", null, "类型"), h("th", null, "大小"), h("th", null, "修改时间"), h("th", null, "fs_id"), h("th", null, "操作"))),
|
||||
h("tbody", null, files.map((file: any) => h("tr", { key: file.fsId || file.path },
|
||||
h("td", null, h("strong", null, file.serverFilename || file.path), h("code", null, file.path || "--")),
|
||||
h("td", null, h(StatusBadge, { status: file.isDir ? "queued" : "private" }, file.isDir ? "DIR" : "FILE")),
|
||||
h("td", null, file.isDir ? "--" : fmtBytes(file.size)),
|
||||
h("td", null, file.serverMtime ? fmtDate(file.serverMtime * 1000) : "--"),
|
||||
h("td", null, h("code", null, file.fsId || "--")),
|
||||
h("td", null, h("div", { className: "inline-actions" },
|
||||
file.isDir ? h("button", { type: "button", className: "ghost-btn", onClick: () => { setCurrentDir(file.path); void loadFiles(file.path); } }, "打开") :
|
||||
h("button", { type: "button", className: "ghost-btn", onClick: () => setDownloadForm((prev: any) => ({ ...prev, fsId: file.fsId })) }, "填入下载"),
|
||||
h("button", { type: "button", className: "ghost-btn", onClick: () => deleteRemote(file.path), disabled: state.actionLoading }, "删除"),
|
||||
)),
|
||||
))),
|
||||
)),
|
||||
),
|
||||
h("div", { className: "baidu-netdisk-grid" },
|
||||
h(Panel, {
|
||||
title: "配置与文档",
|
||||
@@ -474,38 +506,6 @@ export function BaiduNetdiskPage({ microservices, onRaw, apiBaseUrl = "/api" }:
|
||||
h("div", { className: "microservice-ref-card" }, h("span", null, "Quota"), h("strong", null, `${fmtBytes(quota.used)} / ${fmtBytes(quota.total)}`), h("code", null, `${quota.usedPercent || 0}% used`)),
|
||||
) : h(EmptyState, { title: "尚未登录", text: "扫码授权后这里会显示账号、UID、会员状态和容量" }),
|
||||
),
|
||||
h(Panel, { title: "文件浏览器", eyebrow: currentDir, className: "baidu-files-panel", loading: state.loading,
|
||||
actions: h("div", { className: "panel-actions inline-actions" },
|
||||
h("button", { type: "button", className: "ghost-btn", onClick: () => { const parent = pathParent(currentDir, appRoot); setCurrentDir(parent); void loadFiles(parent); }, disabled: !loggedIn || currentDir === appRoot }, "上级"),
|
||||
h("button", { type: "button", className: "ghost-btn", onClick: () => loadFiles(currentDir), disabled: !loggedIn }, "刷新文件"),
|
||||
h(RawButton, { title: "Baidu Files", data: state.files, onOpen: onRaw, testId: "raw-baidu-files" }),
|
||||
),
|
||||
},
|
||||
h("form", { className: "baidu-pathbar", onSubmit: (event: any) => { event.preventDefault(); void loadFiles(currentDir); } },
|
||||
h("input", { value: currentDir, onChange: (event: any) => setCurrentDir(event.target.value), disabled: !loggedIn }),
|
||||
h("button", { type: "submit", className: "ghost-btn", disabled: !loggedIn }, "打开路径"),
|
||||
),
|
||||
h("form", { className: "baidu-pathbar", onSubmit: createFolder },
|
||||
h("input", { value: folderName, onChange: (event: any) => setFolderName(event.target.value), placeholder: "新文件夹名称", disabled: !loggedIn }),
|
||||
h("button", { type: "submit", className: "primary-btn", disabled: !loggedIn || !folderName.trim() }, "新建文件夹"),
|
||||
),
|
||||
!loggedIn ? h(EmptyState, { title: "等待授权", text: "登录后通过 /api/files 读取工作目录文件列表" }) : files.length === 0 ? h(EmptyState, { title: "目录为空", text: "可以从 staging 目录上传文件或新建文件夹" }) :
|
||||
h("div", { className: "table-wrap", "data-testid": "baidu-netdisk-file-table" }, h("table", null,
|
||||
h("thead", null, h("tr", null, h("th", null, "名称"), h("th", null, "类型"), h("th", null, "大小"), h("th", null, "修改时间"), h("th", null, "fs_id"), h("th", null, "操作"))),
|
||||
h("tbody", null, files.map((file: any) => h("tr", { key: file.fsId || file.path },
|
||||
h("td", null, h("strong", null, file.serverFilename || file.path), h("code", null, file.path || "--")),
|
||||
h("td", null, h(StatusBadge, { status: file.isDir ? "queued" : "private" }, file.isDir ? "DIR" : "FILE")),
|
||||
h("td", null, file.isDir ? "--" : fmtBytes(file.size)),
|
||||
h("td", null, file.serverMtime ? fmtDate(file.serverMtime * 1000) : "--"),
|
||||
h("td", null, h("code", null, file.fsId || "--")),
|
||||
h("td", null, h("div", { className: "inline-actions" },
|
||||
file.isDir ? h("button", { type: "button", className: "ghost-btn", onClick: () => { setCurrentDir(file.path); void loadFiles(file.path); } }, "打开") :
|
||||
h("button", { type: "button", className: "ghost-btn", onClick: () => setDownloadForm((prev: any) => ({ ...prev, fsId: file.fsId })) }, "填入下载"),
|
||||
h("button", { type: "button", className: "ghost-btn", onClick: () => deleteRemote(file.path), disabled: state.actionLoading }, "删除"),
|
||||
)),
|
||||
))),
|
||||
)),
|
||||
),
|
||||
h(Panel, { title: "传输任务", eyebrow: "staging path jobs", className: "baidu-transfers-panel", loading: state.actionLoading,
|
||||
actions: h("div", { className: "panel-actions inline-actions" },
|
||||
h("button", { type: "button", className: "primary-btn", onClick: runSelfTest, disabled: !loggedIn || state.actionLoading, "data-testid": "baidu-netdisk-self-test" }, "运行自测"),
|
||||
|
||||
@@ -150,6 +150,10 @@ function codexApi(apiBaseUrl: string, path: string): string {
|
||||
return `${apiBaseUrl}/code-queue-direct${path}`;
|
||||
}
|
||||
|
||||
function codexNoCacheOptions(): AnyRecord {
|
||||
return { headers: { "cache-control": "no-cache", "x-unidesk-no-cache": "1" } };
|
||||
}
|
||||
|
||||
function taskRows(data: any): any[] {
|
||||
return Array.isArray(data?.tasks) ? data.tasks : [];
|
||||
}
|
||||
@@ -163,6 +167,19 @@ function taskSortValue(task: any): number {
|
||||
return Number.isFinite(time) ? time : 0;
|
||||
}
|
||||
|
||||
function taskQueueOrderValue(task: any): number {
|
||||
const time = timestampMs(task?.queueEnteredAt) ?? timestampMs(task?.createdAt) ?? timestampMs(task?.updatedAt);
|
||||
return time ?? 0;
|
||||
}
|
||||
|
||||
function compareTaskQueueOrder(left: any, right: any): number {
|
||||
const queueDelta = taskQueueOrderValue(left) - taskQueueOrderValue(right);
|
||||
if (queueDelta !== 0) return queueDelta;
|
||||
const createdDelta = (timestampMs(left?.createdAt) ?? 0) - (timestampMs(right?.createdAt) ?? 0);
|
||||
if (createdDelta !== 0) return createdDelta;
|
||||
return String(left?.id || "").localeCompare(String(right?.id || ""));
|
||||
}
|
||||
|
||||
function mergeTaskRows(groups: any[][], activeTaskId = ""): any[] {
|
||||
const byId = new Map<string, any>();
|
||||
for (const group of groups) {
|
||||
@@ -292,39 +309,25 @@ function queueRunnableTaskId(queueRows: any[], queueId: string, rows: any[]): st
|
||||
return String(rows.find((task: any) => String(task?.status || "") === "queued" || String(task?.status || "") === "retry_wait")?.id || "");
|
||||
}
|
||||
|
||||
async function loadTaskQueue(apiBaseUrl: string, healthResult: any, queueId = allQueuesId, searchQuery = ""): Promise<any> {
|
||||
const suffix = taskListQuerySuffix(queueId, searchQuery);
|
||||
try {
|
||||
return await requestJson(codexApi(apiBaseUrl, `/api/tasks?limit=${codexInitialTaskLimit}&lite=1&devReady=0${suffix}`));
|
||||
} catch {
|
||||
const statuses = ["running", "judging", "retry_wait", "queued"];
|
||||
const results = await Promise.all(statuses.map(async (status) => {
|
||||
try {
|
||||
return await requestJson(codexApi(apiBaseUrl, `/api/tasks?status=${encodeURIComponent(status)}&limit=80&lite=1&devReady=0${suffix}`));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}));
|
||||
const historyResult = await requestJson(codexApi(apiBaseUrl, `/api/tasks?limit=${codexInitialTaskLimit}&lite=1&devReady=0${suffix}`)).catch(() => null);
|
||||
const queue = results.find((item) => item?.queue)?.queue || historyResult?.queue || healthResult?.queue || healthResult?.body?.queue || {};
|
||||
const rows = mergeTaskRows([...results.map((item) => taskRows(item)), taskRows(historyResult)], String(queue?.activeTaskId || ""));
|
||||
if (rows.length > 0) return { ok: true, queue, statistics: historyResult?.statistics || results.find((item) => item?.statistics)?.statistics || null, tasks: rows };
|
||||
return requestJson(codexApi(apiBaseUrl, `/api/tasks?limit=5&lite=1&devReady=0${suffix}`));
|
||||
}
|
||||
async function loadTaskList(apiBaseUrl: string, queueId = allQueuesId, searchQuery = ""): Promise<any> {
|
||||
return requestJson(codexApi(
|
||||
apiBaseUrl,
|
||||
`/api/tasks/overview?limit=${codexInitialTaskLimit}&transcriptLimit=1&compact=1&selected=0${taskListQuerySuffix(queueId, searchQuery)}`,
|
||||
), codexNoCacheOptions());
|
||||
}
|
||||
|
||||
async function loadTaskOverview(apiBaseUrl: string, preferId: string, afterSeq = 0, queueId = allQueuesId, searchQuery = ""): Promise<any> {
|
||||
return requestJson(codexApi(
|
||||
apiBaseUrl,
|
||||
`/api/tasks/overview?limit=${codexInitialTaskLimit}&transcriptLimit=3&compact=1&afterSeq=${encodeURIComponent(String(Math.max(0, afterSeq)))}&preferId=${encodeURIComponent(preferId)}${taskListQuerySuffix(queueId, searchQuery)}`,
|
||||
));
|
||||
), codexNoCacheOptions());
|
||||
}
|
||||
|
||||
async function loadTaskPage(apiBaseUrl: string, queueId: string, beforeId: string, limit = codexMoreTaskLimit, searchQuery = ""): Promise<any> {
|
||||
return requestJson(codexApi(
|
||||
apiBaseUrl,
|
||||
`/api/tasks/overview?limit=${encodeURIComponent(String(limit))}&transcriptLimit=1&compact=1&selected=0&includeActive=0&stats=0&beforeId=${encodeURIComponent(beforeId)}${taskListQuerySuffix(queueId, searchQuery)}`,
|
||||
));
|
||||
), codexNoCacheOptions());
|
||||
}
|
||||
|
||||
async function loadTaskTraceSummary(apiBaseUrl: string, taskId: string): Promise<any> {
|
||||
@@ -564,7 +567,14 @@ function taskExecutionSummary(task: any): AnyRecord {
|
||||
return execution && typeof execution === "object" && !Array.isArray(execution) ? execution : {};
|
||||
}
|
||||
|
||||
function rawTaskStepCount(task: any): number {
|
||||
function nonNegativeCount(value: any): number {
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) && numeric >= 0 ? Math.floor(numeric) : 0;
|
||||
}
|
||||
|
||||
function canonicalTaskStepCount(task: any): number {
|
||||
const summary = taskTraceSummary(task);
|
||||
if (summary !== null) return traceSummaryStepCount(summary);
|
||||
const value = Number(task?.stepCount ?? task?.llmStepCount ?? 0);
|
||||
return Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0;
|
||||
}
|
||||
@@ -572,7 +582,7 @@ function rawTaskStepCount(task: any): number {
|
||||
function traceSummaryStepCount(summary: AnyRecord | null): number {
|
||||
const execution = objectRecord(summary?.execution) || {};
|
||||
const value = Number(summary?.stepCount ?? summary?.llmStepCount ?? execution.stepCount ?? execution.llmStepCount ?? execution.toolCallCount ?? 0);
|
||||
return Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0;
|
||||
return nonNegativeCount(value);
|
||||
}
|
||||
|
||||
function traceSummaryIsCurrent(task: any): boolean {
|
||||
@@ -589,8 +599,7 @@ function traceSummaryIsCurrent(task: any): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const rawSteps = rawTaskStepCount(task);
|
||||
return rawSteps <= 0 || traceSummaryStepCount(summary) >= rawSteps;
|
||||
return true;
|
||||
}
|
||||
|
||||
function taskBasePromptText(task: any): string {
|
||||
@@ -618,16 +627,6 @@ function objectRecord(value: any): AnyRecord | null {
|
||||
function taskProgressiveAttempts(task: any): any[] {
|
||||
const summaryAttempts = taskTraceSummary(task)?.attempts;
|
||||
if (Array.isArray(summaryAttempts) && summaryAttempts.length > 0) return summaryAttempts;
|
||||
const attempts = taskAttempts(task);
|
||||
if (attempts.length > 0) {
|
||||
return attempts.map((attempt: any, index: number) => ({
|
||||
...attempt,
|
||||
index: Number(attempt?.index || index + 1),
|
||||
execution: index === attempts.length - 1 ? taskExecutionSummary(task) : objectRecord(attempt?.execution) || {},
|
||||
finalResponse: String(attempt?.finalResponse || attempt?.finalResponsePreview || (index === attempts.length - 1 ? taskFinalResponseText(task) : "")),
|
||||
judge: objectRecord(attempt?.judge) || (index === attempts.length - 1 ? taskLastJudge(task) : null),
|
||||
}));
|
||||
}
|
||||
const execution = taskExecutionSummary(task);
|
||||
const finalResponse = taskFinalResponseText(task);
|
||||
const judge = taskLastJudge(task);
|
||||
@@ -743,6 +742,25 @@ function taskTraceSteps(task: any, attemptIndex: any = null): any[] {
|
||||
return Array.isArray(task?._traceSteps) ? task._traceSteps : [];
|
||||
}
|
||||
|
||||
function traceStepSeqValue(step: any): number {
|
||||
const candidates = [step?.seq, ...(Array.isArray(step?.rawSeqs) ? step.rawSeqs : [])].map((value) => Number(value));
|
||||
const finite = candidates.filter((value) => Number.isFinite(value));
|
||||
return finite.length > 0 ? Math.max(...finite) : 0;
|
||||
}
|
||||
|
||||
function traceStepsMaxSeq(steps: any[]): number {
|
||||
return (Array.isArray(steps) ? steps : []).reduce((max, step) => Math.max(max, traceStepSeqValue(step)), 0);
|
||||
}
|
||||
|
||||
function mergeTraceStepRows(existing: any[], incoming: any[]): any[] {
|
||||
const byKey = new Map<string, any>();
|
||||
for (const step of [...(Array.isArray(existing) ? existing : []), ...(Array.isArray(incoming) ? incoming : [])]) {
|
||||
const key = String(step?.seq ?? `${step?.title || "step"}:${step?.at || ""}`);
|
||||
byKey.set(key, { ...(byKey.get(key) || {}), ...step });
|
||||
}
|
||||
return Array.from(byKey.values()).sort((left, right) => traceStepSeqValue(left) - traceStepSeqValue(right));
|
||||
}
|
||||
|
||||
function traceStepSummaryLines(step: any): string[] {
|
||||
return (Array.isArray(step?.summaryLines) ? step.summaryLines : []).map((line: any) => String(line || ""));
|
||||
}
|
||||
@@ -834,39 +852,9 @@ function coalesceFileChangeTraceSteps(steps: any[]): any[] {
|
||||
return merged;
|
||||
}
|
||||
|
||||
function toolStepCountFromExecution(execution: AnyRecord): number {
|
||||
const value = Number(execution?.toolCallCount);
|
||||
if (Number.isFinite(value) && value >= 0) return Math.floor(value);
|
||||
const total = Number(execution?.readCount || 0) + Number(execution?.editCount || 0) + Number(execution?.runCount || 0);
|
||||
return Number.isFinite(total) && total >= 0 ? Math.floor(total) : 0;
|
||||
}
|
||||
|
||||
function toolStepCountsFromTraceSteps(steps: any[]): AnyRecord {
|
||||
const rows = Array.isArray(steps) ? steps : [];
|
||||
const counts = rows.reduce((memo: AnyRecord, step: any) => {
|
||||
const kind = String(step?.kind || "");
|
||||
if (kind === "explored") memo.readCount += 1;
|
||||
else if (kind === "edited") memo.editCount += 1;
|
||||
else if (kind === "ran") memo.runCount += 1;
|
||||
return memo;
|
||||
}, { readCount: 0, editCount: 0, runCount: 0 });
|
||||
counts.toolCallCount = counts.readCount + counts.editCount + counts.runCount;
|
||||
return counts;
|
||||
}
|
||||
|
||||
function executionSummaryWithDisplayedSteps(execution: AnyRecord, steps: any[]): AnyRecord {
|
||||
if (steps.length === 0) {
|
||||
const stepCount = toolStepCountFromExecution(execution);
|
||||
return { ...execution, stepCount, llmStepCount: stepCount };
|
||||
}
|
||||
const counts = toolStepCountsFromTraceSteps(steps);
|
||||
return {
|
||||
...execution,
|
||||
...counts,
|
||||
stepCount: counts.toolCallCount,
|
||||
llmStepCount: counts.toolCallCount,
|
||||
traceLineCount: steps.length,
|
||||
};
|
||||
function canonicalExecutionSummary(execution: AnyRecord): AnyRecord {
|
||||
const stepCount = nonNegativeCount(execution?.stepCount ?? execution?.llmStepCount ?? execution?.toolCallCount);
|
||||
return { ...execution, stepCount, llmStepCount: stepCount };
|
||||
}
|
||||
|
||||
function taskTraceStepsLoaded(task: any, attemptIndex: any = null): boolean {
|
||||
@@ -1120,7 +1108,7 @@ function mergeTaskPatch(existingTask: any, patch: AnyRecord): AnyRecord {
|
||||
merged._transcriptPreview = Object.prototype.hasOwnProperty.call(patch, "_transcriptPreview")
|
||||
? patch._transcriptPreview
|
||||
: existingTask?._transcriptPreview;
|
||||
for (const key of ["_traceSummary", "_traceSummaryLoaded", "_traceSteps", "_traceStepsLoaded", "_traceStepsByAttempt", "_traceStepsLoadedByAttempt", "_traceStepDetails", "_promptDetails"]) {
|
||||
for (const key of ["_traceSummary", "_traceSummaryLoaded", "_traceSteps", "_traceStepsLoaded", "_traceStepsByAttempt", "_traceStepsLoadedByAttempt", "_traceStepsNextAfterSeqByAttempt", "_traceStepDetails", "_promptDetails"]) {
|
||||
if (!Object.prototype.hasOwnProperty.call(patch, key) && Object.prototype.hasOwnProperty.call(existingTask || {}, key)) {
|
||||
merged[key] = existingTask[key];
|
||||
}
|
||||
@@ -1222,15 +1210,7 @@ function providerDefaultWorkdir(queue: any, providerId: string): string {
|
||||
}
|
||||
|
||||
function taskStepCount(task: any): number {
|
||||
const attempts = taskProgressiveAttempts(task).filter((attempt: any) => !isSyntheticAttemptSegment(attempt, attempt?.index));
|
||||
if (attempts.length > 0) {
|
||||
const attemptTotal = attempts.reduce((sum: number, attempt: any) => sum + toolStepCountFromExecution(attemptExecutionSummary(task, attempt)), 0);
|
||||
if (attemptTotal > 0) return attemptTotal;
|
||||
}
|
||||
const executionTotal = toolStepCountFromExecution(taskExecutionSummary(task));
|
||||
if (executionTotal > 0) return executionTotal;
|
||||
const apiTotal = Number(task?.stepCount ?? task?.llmStepCount ?? 0);
|
||||
return Number.isFinite(apiTotal) && apiTotal >= 0 ? Math.floor(apiTotal) : 0;
|
||||
return canonicalTaskStepCount(task);
|
||||
}
|
||||
|
||||
function TaskCard({ task, selected, onSelect, onCopy, onReference, onMarkRead, copied, markingRead }: AnyRecord) {
|
||||
@@ -1556,14 +1536,13 @@ function ProgressivePromptBlock({ task, loading, onLoadPromptPart, testId = "cod
|
||||
|
||||
function ProgressiveExecutionSummary({ task, attempt, attemptIndex, loading, onLoadSteps, onLoadStep, testId = "codex-execution-summary" }: AnyRecord) {
|
||||
const steps = coalesceFileChangeTraceSteps(taskTraceSteps(task, attemptIndex));
|
||||
const execution = executionSummaryWithDisplayedSteps(attemptExecutionSummary(task, attempt), steps);
|
||||
const execution = canonicalExecutionSummary(attemptExecutionSummary(task, attempt));
|
||||
const summary = taskTraceSummary(task);
|
||||
const stepDetails = taskTraceStepDetails(task);
|
||||
const stepsLoaded = taskTraceStepsLoaded(task, attemptIndex);
|
||||
const errorCount = Number(attempt?.errorCount ?? summary?.errorCount ?? traceStepErrorCount(steps));
|
||||
const toolCount = Number(execution.toolCallCount || 0);
|
||||
const summaryStepCountValue = Number(execution.stepCount ?? execution.llmStepCount ?? 0);
|
||||
const summaryStepCount = Number.isFinite(summaryStepCountValue) && summaryStepCountValue >= 0 ? Math.floor(summaryStepCountValue) : 0;
|
||||
const toolCount = nonNegativeCount(execution.toolCallCount);
|
||||
const stepCount = nonNegativeCount(execution.stepCount ?? execution.llmStepCount ?? execution.toolCallCount);
|
||||
const editedFiles = Array.isArray(execution.editedFiles) ? execution.editedFiles : [];
|
||||
const commands = Array.isArray(execution.commands) ? execution.commands : [];
|
||||
const synthetic = isSyntheticAttemptSegment(attempt, attemptIndex);
|
||||
@@ -1571,11 +1550,6 @@ function ProgressiveExecutionSummary({ task, attempt, attemptIndex, loading, onL
|
||||
const updatedAt = attemptExecutionUpdatedAt(task, attempt, attemptIndex, execution);
|
||||
const recentUpdateLabel = `最近更新: ${fmtRelativeAge(updatedAt)}`;
|
||||
const running = attemptExecutionIsRunning(task, attempt, attemptIndex);
|
||||
const rawSteps = rawTaskStepCount(task);
|
||||
const realAttemptCount = taskProgressiveAttempts(task).filter((item: any) => !isSyntheticAttemptSegment(item, item?.index)).length;
|
||||
const canUseTaskStepFloor = !synthetic && steps.length === 0 && rawSteps > 0 && realAttemptCount <= 1;
|
||||
const stepCount = canUseTaskStepFloor ? Math.max(summaryStepCount, rawSteps) : summaryStepCount;
|
||||
const displayedToolCount = canUseTaskStepFloor ? Math.max(toolCount, stepCount) : toolCount;
|
||||
return h("details", {
|
||||
className: `codex-progressive-card codex-execution-summary ${running ? "running" : ""}`,
|
||||
"data-testid": testId,
|
||||
@@ -1591,13 +1565,13 @@ function ProgressiveExecutionSummary({ task, attempt, attemptIndex, loading, onL
|
||||
h("strong", null, `执行过程摘要${labelSuffix}`),
|
||||
running ? h("span", { className: "codex-summary-running-pill", "data-testid": `${testId}-running` }, "执行中") : null,
|
||||
h("code", { title: updatedAt ? `最近更新: ${fmtDate(updatedAt)}` : recentUpdateLabel },
|
||||
`${fmtDuration(execution.durationMs ?? execution.totalElapsedMs)} / ${displayedToolCount} tools / ${recentUpdateLabel}`),
|
||||
`${fmtDuration(execution.durationMs ?? execution.totalElapsedMs)} / ${toolCount} tools / ${recentUpdateLabel}`),
|
||||
),
|
||||
h("div", { className: "codex-execution-digest" },
|
||||
h("span", null, `read ${Number(execution.readCount || 0)}`),
|
||||
h("span", null, `edit ${Number(execution.editCount || 0)}`),
|
||||
h("span", null, `run ${Number(execution.runCount || 0)}`),
|
||||
h("span", null, `STEP ${Number.isFinite(stepCount) ? Math.max(0, Math.floor(stepCount)) : 0}`),
|
||||
h("span", null, `STEP ${stepCount}`),
|
||||
errorCount > 0 ? h("span", { className: "codex-execution-error-pill", "data-testid": `${testId}-error-count` }, `Error ${errorCount}`) : null,
|
||||
),
|
||||
),
|
||||
@@ -1853,6 +1827,9 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
|
||||
const traceStepsInFlightRef = useRef<Map<string, Promise<void>>>(new Map());
|
||||
const traceStepInFlightRef = useRef<Map<string, Promise<void>>>(new Map());
|
||||
const autoTraceLoadKeysRef = useRef<Set<string>>(new Set());
|
||||
const eventRefreshTimerRef = useRef<number | null>(null);
|
||||
const traceEventRefreshTimerRef = useRef<number | null>(null);
|
||||
const traceEventRefreshPendingStepsRef = useRef(false);
|
||||
const trackedLoadInFlightRef = useRef(false);
|
||||
const initialLoadSkippedRef = useRef(Boolean(initialTasksData));
|
||||
const locallyReadTaskIdsRef = useRef<Map<string, string>>(new Map());
|
||||
@@ -1931,7 +1908,7 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
|
||||
const searchPagination = taskPagination(searchTasksData);
|
||||
const sidebarTasks = searchActive ? searchTasks : tasks;
|
||||
const unreadTerminalTasks = sidebarTasks.filter(taskIsUnreadTerminal);
|
||||
const queuedTasks = sidebarTasks.filter((task: any) => !taskIsTerminal(task));
|
||||
const queuedTasks = sidebarTasks.filter((task: any) => !taskIsTerminal(task)).sort(compareTaskQueueOrder);
|
||||
const historyTasks = sidebarTasks.filter((task: any) => taskIsTerminal(task) && !taskIsUnreadTerminal(task));
|
||||
const sidebarPagination = searchActive ? searchPagination : pagination;
|
||||
const sidebarTotalTaskCount = searchActive ? Number(searchPagination.total ?? searchTasks.length) : totalTaskCount;
|
||||
@@ -2071,7 +2048,7 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
|
||||
const timer = window.setTimeout(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await loadTaskQueue(apiBaseUrl, {}, selectedQueueId, query);
|
||||
const result = await loadTaskList(apiBaseUrl, selectedQueueId, query);
|
||||
if (token !== searchLoadTokenRef.current) return;
|
||||
setSearchTasksData(applyLocalReadStateToResult(result));
|
||||
} catch (err) {
|
||||
@@ -2146,28 +2123,21 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
|
||||
const result = await loadTaskTraceSummary(apiBaseUrl, taskId);
|
||||
if (token !== detailLoadTokenRef.current || selectedIdRef.current !== taskId) return;
|
||||
const summary = result?.summary || {};
|
||||
const currentTask = sessionCacheRef.current.get(taskId)?.task || {};
|
||||
const summaryUpdatedAt = String(summary.updatedAt || "");
|
||||
const currentUpdatedAt = String(currentTask?.updatedAt || "");
|
||||
const summaryBehind = timestampIsAfter(currentUpdatedAt, summaryUpdatedAt) || rawTaskStepCount(currentTask) > traceSummaryStepCount(summary);
|
||||
if (summaryBehind) inFlight.refreshAfter = true;
|
||||
const effectiveUpdatedAt = summaryBehind
|
||||
? latestTimestampValue(currentUpdatedAt, summaryUpdatedAt)
|
||||
: summaryUpdatedAt || currentUpdatedAt;
|
||||
publishCachedTask(taskId, {
|
||||
id: taskId,
|
||||
status: summaryBehind ? (currentTask?.status || summary.status) : summary.status,
|
||||
updatedAt: effectiveUpdatedAt,
|
||||
startedAt: summary.startedAt || currentTask?.startedAt,
|
||||
finishedAt: summaryBehind ? (currentTask?.finishedAt || summary.finishedAt) : summary.finishedAt,
|
||||
currentAttempt: summary.currentAttempt ?? currentTask?.currentAttempt,
|
||||
maxAttempts: summary.maxAttempts ?? currentTask?.maxAttempts,
|
||||
finalResponse: summaryBehind ? preferLongerText(currentTask, summary, "finalResponse") : summary.finalResponse,
|
||||
lastJudge: summaryBehind ? (currentTask?.lastJudge || summary.lastJudge) : summary.lastJudge,
|
||||
lastError: summaryBehind ? (currentTask?.lastError || summary.lastError) : summary.lastError,
|
||||
attempts: summaryBehind
|
||||
? preferRicherArray(currentTask, { attempts: Array.isArray(summary.attempts) ? summary.attempts : [] }, "attempts")
|
||||
: Array.isArray(summary.attempts) ? summary.attempts : [],
|
||||
status: summary.status,
|
||||
updatedAt: summaryUpdatedAt,
|
||||
startedAt: summary.startedAt,
|
||||
finishedAt: summary.finishedAt,
|
||||
currentAttempt: summary.currentAttempt,
|
||||
maxAttempts: summary.maxAttempts,
|
||||
finalResponse: summary.finalResponse,
|
||||
lastJudge: summary.lastJudge,
|
||||
lastError: summary.lastError,
|
||||
attempts: Array.isArray(summary.attempts) ? summary.attempts : [],
|
||||
stepCount: summary.stepCount,
|
||||
llmStepCount: summary.llmStepCount,
|
||||
timing: summary.timing,
|
||||
_traceSummary: summary,
|
||||
_traceSummaryLoaded: true,
|
||||
@@ -2232,29 +2202,37 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
|
||||
await promise;
|
||||
}
|
||||
|
||||
async function ensureTraceSteps(attemptIndex: any = null): Promise<void> {
|
||||
async function ensureTraceSteps(attemptIndex: any = null, options: AnyRecord = {}): Promise<void> {
|
||||
const taskId = selectedIdRef.current;
|
||||
if (!service || !taskId) return;
|
||||
const cachedTask = sessionCacheRef.current.get(taskId)?.task;
|
||||
const attemptKey = attemptIndex === null || attemptIndex === undefined || String(attemptIndex).length === 0 ? "" : String(attemptIndex);
|
||||
if (taskTraceStepsLoaded(cachedTask, attemptKey || null)) return;
|
||||
const key = `${taskId}:${attemptKey || "all"}`;
|
||||
const loaded = taskTraceStepsLoaded(cachedTask, attemptKey || null);
|
||||
const force = Boolean(options.force);
|
||||
const incremental = Boolean(options.incremental);
|
||||
if (loaded && !force) return;
|
||||
const existingSteps = taskTraceSteps(cachedTask, attemptKey || null);
|
||||
const afterSeq = incremental && existingSteps.length > 0 ? traceStepsMaxSeq(existingSteps) : 0;
|
||||
const key = `${taskId}:${attemptKey || "all"}:${afterSeq}`;
|
||||
const existing = traceStepsInFlightRef.current.get(key);
|
||||
if (existing) return existing;
|
||||
const token = detailLoadTokenRef.current;
|
||||
if (selectedIdRef.current === taskId) setSelectedDetailLoading(true);
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const result = await loadTaskTraceSteps(apiBaseUrl, taskId, 0, 500, attemptKey || null);
|
||||
const result = await loadTaskTraceSteps(apiBaseUrl, taskId, afterSeq, 500, attemptKey || null);
|
||||
if (token !== detailLoadTokenRef.current || selectedIdRef.current !== taskId) return;
|
||||
const steps = Array.isArray(result?.steps) ? result.steps : [];
|
||||
const incomingSteps = Array.isArray(result?.steps) ? result.steps : [];
|
||||
const steps = afterSeq > 0 ? mergeTraceStepRows(existingSteps, incomingSteps) : incomingSteps;
|
||||
if (attemptKey) {
|
||||
const currentTask = sessionCacheRef.current.get(taskId)?.task;
|
||||
const byAttempt = objectRecord(currentTask?._traceStepsByAttempt) || {};
|
||||
const loadedByAttempt = objectRecord(currentTask?._traceStepsLoadedByAttempt) || {};
|
||||
const nextSeqByAttempt = objectRecord(currentTask?._traceStepsNextAfterSeqByAttempt) || {};
|
||||
publishCachedTask(taskId, {
|
||||
_traceStepsByAttempt: { ...byAttempt, [attemptKey]: steps },
|
||||
_traceStepsLoadedByAttempt: { ...loadedByAttempt, [attemptKey]: true },
|
||||
_traceStepsNextAfterSeqByAttempt: { ...nextSeqByAttempt, [attemptKey]: result?.nextAfterSeq },
|
||||
}, token);
|
||||
} else {
|
||||
publishCachedTask(taskId, {
|
||||
@@ -2303,6 +2281,19 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
|
||||
await promise;
|
||||
}
|
||||
|
||||
function refreshLoadedTraceSteps(taskId: string): void {
|
||||
if (selectedIdRef.current !== taskId) return;
|
||||
const cachedTask = sessionCacheRef.current.get(taskId)?.task;
|
||||
if (!cachedTask) return;
|
||||
if (taskTraceStepsLoaded(cachedTask, null)) {
|
||||
void ensureTraceSteps(null, { force: true, incremental: true }).catch((err) => setError(errorText(err, "增量刷新 Trace Steps 失败")));
|
||||
}
|
||||
const loadedByAttempt = objectRecord(cachedTask?._traceStepsLoadedByAttempt) || {};
|
||||
for (const attemptKey of Object.keys(loadedByAttempt).filter((key) => loadedByAttempt[key])) {
|
||||
void ensureTraceSteps(attemptKey, { force: true, incremental: true }).catch((err) => setError(errorText(err, "增量刷新 Attempt Trace Steps 失败")));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTaskDetail(taskId: string, loadStartedAt?: number, queueMs?: number): Promise<void> {
|
||||
if (!service || !taskId) return;
|
||||
const detailStartedAt = performance.now();
|
||||
@@ -2405,19 +2396,14 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
|
||||
const requestedCached = requestedId ? sessionCacheRef.current.get(requestedId) : null;
|
||||
const requestedTranscript = Array.isArray(requestedCached?.task?.transcript) ? requestedCached.task.transcript : [];
|
||||
const overviewAfterSeq = transcriptResumeSeq(requestedTranscript);
|
||||
const healthResult = health || {};
|
||||
let tasksResult = null;
|
||||
try {
|
||||
tasksResult = await loadTaskOverview(apiBaseUrl, requestedId, overviewAfterSeq, queueFilterId);
|
||||
} catch {
|
||||
tasksResult = await loadTaskQueue(apiBaseUrl, healthResult, queueFilterId);
|
||||
}
|
||||
tasksResult = await loadTaskOverview(apiBaseUrl, requestedId, overviewAfterSeq, queueFilterId);
|
||||
if (token !== queueLoadTokenRef.current) {
|
||||
if (trackLoad) trackedLoadInFlightRef.current = false;
|
||||
return;
|
||||
}
|
||||
const queueMs = performance.now() - startedAt;
|
||||
setHealth(healthResult);
|
||||
setHealth(health || {});
|
||||
const resultQueue = tasksResult?.queue || {};
|
||||
const activeSortId = String(resultQueue?.activeTaskId || activeTaskIds(resultQueue)[0] || "");
|
||||
let mergedTasksResult = tasksResult;
|
||||
@@ -2447,9 +2433,7 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
|
||||
const overviewSelected = mergedTasksResult?.selected || null;
|
||||
const overviewTask = overviewSelected?.task || null;
|
||||
const overviewTranscript = Array.isArray(overviewSelected?.transcript) ? overviewSelected.transcript : null;
|
||||
const nextId = latestRequestedId && (rows.some((task: any) => task.id === latestRequestedId) || String(overviewTask?.id || "") === latestRequestedId)
|
||||
? latestRequestedId
|
||||
: (queuePrimaryId || queueRunnableId || rows[0]?.id || "");
|
||||
const nextId = latestRequestedId || queuePrimaryId || queueRunnableId || rows[0]?.id || "";
|
||||
const previousId = selectedIdRef.current;
|
||||
if (previousId !== nextId) detailLoadTokenRef.current += 1;
|
||||
selectedIdRef.current = nextId;
|
||||
@@ -2774,6 +2758,41 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
|
||||
}, "修改 Codex queue 名称失败");
|
||||
}
|
||||
|
||||
async function mergeQueue(): Promise<void> {
|
||||
const targetQueueId = String(queueId || "default").trim() || "default";
|
||||
const candidates = queueRows.filter((row: any) => String(row?.id || "") !== targetQueueId);
|
||||
if (candidates.length === 0) {
|
||||
const msg = "没有可合并的其他 queue;请先创建或选择另一个 queue。";
|
||||
setNotice(msg);
|
||||
return;
|
||||
}
|
||||
const proposed = typeof window === "undefined"
|
||||
? ""
|
||||
: window.prompt(`将源 queue 合并到当前 queue=${targetQueueId};源 queue 记录会保留且不会删除。请输入源 queue ID:`, String(candidates[0]?.id || ""));
|
||||
const sourceQueueId = String(proposed || "").trim();
|
||||
if (!sourceQueueId) return;
|
||||
if (sourceQueueId === targetQueueId) {
|
||||
setError("源 queue 和目标 queue 不能相同。");
|
||||
return;
|
||||
}
|
||||
const confirmed = typeof window === "undefined" ? true : window.confirm(`确认把 queue=${sourceQueueId} 的全部任务合并到 queue=${targetQueueId}?源 queue 不会删除;任务会按原 queueEnteredAt/createdAt 时间顺序进入目标 queue。`);
|
||||
if (!confirmed) return;
|
||||
await guarded(async () => {
|
||||
const result = await requestJson(codexApi(apiBaseUrl, `/api/queues/${encodeURIComponent(targetQueueId)}/merge`), { method: "POST", body: { sourceQueueId } });
|
||||
if (result?.summary) {
|
||||
setTasksData((previous: any) => previous ? { ...previous, queue: result.summary } : previous);
|
||||
}
|
||||
setQueueId(targetQueueId);
|
||||
setSelectedQueueId(targetQueueId);
|
||||
setTasksData(null);
|
||||
const count = Number(result?.mergedTaskCount || 0);
|
||||
const msg = `已将 queue=${sourceQueueId} 合并到 ${targetQueueId},移动 ${count} 个任务;源 queue 已保留。`;
|
||||
setNotice(msg);
|
||||
addNotification("success", msg);
|
||||
await load(selectedIdRef.current, true, targetQueueId);
|
||||
}, "合并 Codex queue 失败");
|
||||
}
|
||||
|
||||
async function enqueue(event: any): Promise<void> {
|
||||
event.preventDefault();
|
||||
if (enqueueInFlightRef.current) {
|
||||
@@ -2853,6 +2872,7 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
|
||||
_traceStepsLoaded: false,
|
||||
_traceStepsByAttempt: {},
|
||||
_traceStepsLoadedByAttempt: {},
|
||||
_traceStepsNextAfterSeqByAttempt: {},
|
||||
_traceStepDetails: {},
|
||||
};
|
||||
sessionCacheRef.current.set(taskId, { ...(sessionCacheRef.current.get(taskId) || {}), task: updatedTask, complete: false, completeUpdatedAt: "" });
|
||||
@@ -2956,6 +2976,82 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
|
||||
if (isMobileQueueViewport()) setQueueSidebarOpen(false);
|
||||
}
|
||||
|
||||
function patchRowsForTaskEvent(data: any, taskId: string, patch: AnyRecord): any {
|
||||
if (!data || !Array.isArray(data?.tasks) || taskId.length === 0 || Object.keys(patch).length === 0) return data;
|
||||
let changed = false;
|
||||
const rows = taskRows(data).map((task: any) => {
|
||||
if (String(task?.id || "") !== taskId) return task;
|
||||
changed = true;
|
||||
return taskWithLocalReadState(mergeTaskPatch(task, patch));
|
||||
});
|
||||
return changed ? { ...data, tasks: rows } : data;
|
||||
}
|
||||
|
||||
function patchSidebarTaskFromEvent(taskId: string, patch: AnyRecord): void {
|
||||
setTasksData((previous: any) => patchRowsForTaskEvent(previous, taskId, patch));
|
||||
setSearchTasksData((previous: any) => patchRowsForTaskEvent(previous, taskId, patch));
|
||||
}
|
||||
|
||||
function taskEventNeedsOverviewRefresh(event: AnyRecord, taskId: string, previousTask: any): boolean {
|
||||
const type = String(event?.type || "");
|
||||
if (type === "queue-updated") return true;
|
||||
if (taskId.length === 0) return true;
|
||||
if (!previousTask) return type !== "task-step";
|
||||
if (event?.queueId && String(event.queueId) !== taskQueueLabel(previousTask)) return true;
|
||||
if (event?.status && String(event.status) !== String(previousTask?.status || "")) return true;
|
||||
return type !== "task-step" && String(event?.reason || "") !== "output";
|
||||
}
|
||||
|
||||
function scheduleEventDrivenOverviewRefresh(): void {
|
||||
if (!service || !isDocumentVisible()) return;
|
||||
if (eventRefreshTimerRef.current !== null) window.clearTimeout(eventRefreshTimerRef.current);
|
||||
eventRefreshTimerRef.current = window.setTimeout(() => {
|
||||
eventRefreshTimerRef.current = null;
|
||||
// SSE refreshes should update the sidebar data around the user's current
|
||||
// selection, not jump to whichever task emitted the latest event.
|
||||
void load(selectedIdRef.current, false).catch((err) => setError(errorText(err, "Code Queue 事件刷新失败")));
|
||||
}, 120);
|
||||
}
|
||||
|
||||
function scheduleSelectedTraceEventRefresh(taskId: string, includeSteps: boolean): void {
|
||||
if (!service || !isDocumentVisible() || selectedIdRef.current !== taskId) return;
|
||||
traceEventRefreshPendingStepsRef.current = traceEventRefreshPendingStepsRef.current || includeSteps;
|
||||
if (traceEventRefreshTimerRef.current !== null) return;
|
||||
traceEventRefreshTimerRef.current = window.setTimeout(() => {
|
||||
traceEventRefreshTimerRef.current = null;
|
||||
const refreshSteps = traceEventRefreshPendingStepsRef.current;
|
||||
traceEventRefreshPendingStepsRef.current = false;
|
||||
void ensureTraceSummary(taskId, true).catch((err) => setError(errorText(err, "事件刷新 Trace Summary 失败")));
|
||||
if (refreshSteps) refreshLoadedTraceSteps(taskId);
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function applyCodeQueueEvent(event: AnyRecord): void {
|
||||
const taskId = String(event?.taskId || "");
|
||||
const stepCount = nonNegativeCount(event?.stepCount);
|
||||
const patch: AnyRecord = {};
|
||||
const previousTask = taskId.length > 0
|
||||
? taskRows(tasksDataRef.current).find((task: any) => String(task?.id || "") === taskId)
|
||||
: null;
|
||||
if (taskId.length > 0) {
|
||||
if (event?.status) patch.status = String(event.status);
|
||||
if (event?.updatedAt) patch.updatedAt = String(event.updatedAt);
|
||||
if (event?.queueId) patch.queueId = String(event.queueId);
|
||||
if (Number.isFinite(Number(event?.stepCount))) {
|
||||
patch.stepCount = stepCount;
|
||||
patch.llmStepCount = stepCount;
|
||||
}
|
||||
if (Object.keys(patch).length > 0 && (sessionCacheRef.current.has(taskId) || selectedIdRef.current === taskId)) {
|
||||
publishCachedTask(taskId, patch, detailLoadTokenRef.current);
|
||||
}
|
||||
if (Object.keys(patch).length > 0) patchSidebarTaskFromEvent(taskId, patch);
|
||||
if (selectedIdRef.current === taskId && (event?.type === "task-step" || event?.type === "task-updated")) {
|
||||
scheduleSelectedTraceEventRefresh(taskId, event?.type === "task-step");
|
||||
}
|
||||
}
|
||||
if (taskEventNeedsOverviewRefresh(event, taskId, previousTask)) scheduleEventDrivenOverviewRefresh();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (initialLoadSkippedRef.current) {
|
||||
initialLoadSkippedRef.current = false;
|
||||
@@ -2965,23 +3061,35 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
|
||||
}, [service?.id, selectedQueueId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!service) return undefined;
|
||||
const tick = (): void => {
|
||||
if (!isDocumentVisible()) return;
|
||||
void load(selectedIdRef.current, false).catch((err) => setError(errorText(err, "Code Queue 轮询失败")));
|
||||
if (!service || typeof EventSource === "undefined") return undefined;
|
||||
const source = new EventSource(codexApi(apiBaseUrl, "/api/events"), { withCredentials: true });
|
||||
const onEvent = (message: MessageEvent): void => {
|
||||
try {
|
||||
applyCodeQueueEvent(JSON.parse(String(message.data || "{}")));
|
||||
} catch (err) {
|
||||
setError(errorText(err, "解析 Code Queue 事件失败"));
|
||||
}
|
||||
};
|
||||
const timer = window.setInterval(() => {
|
||||
tick();
|
||||
}, 1500);
|
||||
const onVisible = (): void => {
|
||||
if (isDocumentVisible()) tick();
|
||||
if (isDocumentVisible()) scheduleEventDrivenOverviewRefresh();
|
||||
};
|
||||
source.addEventListener("task-step", onEvent);
|
||||
source.addEventListener("task-updated", onEvent);
|
||||
source.addEventListener("queue-updated", onEvent);
|
||||
document.addEventListener("visibilitychange", onVisible);
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
source.close();
|
||||
document.removeEventListener("visibilitychange", onVisible);
|
||||
if (eventRefreshTimerRef.current !== null) {
|
||||
window.clearTimeout(eventRefreshTimerRef.current);
|
||||
eventRefreshTimerRef.current = null;
|
||||
}
|
||||
if (traceEventRefreshTimerRef.current !== null) {
|
||||
window.clearTimeout(traceEventRefreshTimerRef.current);
|
||||
traceEventRefreshTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [service?.id, selectedQueueId]);
|
||||
}, [service?.id, apiBaseUrl, selectedQueueId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!service || !selectedTask || selectedDetailLoading) return;
|
||||
@@ -2989,7 +3097,7 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
|
||||
if (!taskId) return;
|
||||
const updatedAt = String(selectedTask.updatedAt || "");
|
||||
if (traceSummaryIsCurrent(selectedTask)) return;
|
||||
const key = `${taskId}:${updatedAt || "unknown"}:${rawTaskStepCount(selectedTask)}:${traceSummaryStepCount(taskTraceSummary(selectedTask))}`;
|
||||
const key = `${taskId}:${updatedAt || "unknown"}:${String(selectedTask?._traceSummaryUpdatedAt || "")}`;
|
||||
if (autoTraceLoadKeysRef.current.has(key)) return;
|
||||
autoTraceLoadKeysRef.current.add(key);
|
||||
void ensureTraceSummary(taskId, true).catch((err) => setError(errorText(err, "自动加载 Trace Summary 失败")));
|
||||
@@ -3188,6 +3296,7 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
|
||||
queueRows.map((row: any) => h("option", { key: String(row?.id || ""), value: String(row?.id || "") }, queueOptionLabel(row))),
|
||||
),
|
||||
h("button", { type: "button", className: "ghost-btn codex-rename-queue-btn", onClick: () => void renameQueue(), disabled: busy || submitting || !queueId, title: "修改当前 queue 的显示名称,ID 不变", "data-testid": "codex-rename-queue-button" }, "改名"),
|
||||
h("button", { type: "button", className: "ghost-btn codex-merge-queue-btn", onClick: () => void mergeQueue(), disabled: busy || submitting || queueRows.length < 2, title: "把其他 queue 合并到当前 queue;源 queue 记录会保留,不会删除", "data-testid": "codex-merge-queue-button" }, "合并 queue"),
|
||||
h("button", { type: "button", className: "ghost-btn codex-create-queue-btn", onClick: () => void createQueue(), disabled: busy || submitting, "data-testid": "codex-create-queue-button" }, "创建 queue"),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -707,9 +707,12 @@ async function proxyCodeQueueDirect(req: Request, url: URL): Promise<Response> {
|
||||
}
|
||||
const overviewCacheKey = `${suffix}${url.search}`;
|
||||
const canUseOverviewCache = req.method === "GET" && suffix === "/api/tasks/overview";
|
||||
const expectsJsonResponse = suffix === "/health" || suffix.startsWith("/api/");
|
||||
const isEventStreamRequest = req.method === "GET" && suffix === "/api/events";
|
||||
const cacheControl = req.headers.get("cache-control") || "";
|
||||
const bypassOverviewCache = /\bno-cache\b|\bno-store\b/iu.test(cacheControl) || req.headers.get("x-unidesk-no-cache") === "1";
|
||||
const expectsJsonResponse = !isEventStreamRequest && (suffix === "/health" || suffix.startsWith("/api/"));
|
||||
if (req.method !== "GET" && req.method !== "HEAD") invalidateCodeQueueOverviewCache();
|
||||
if (canUseOverviewCache) {
|
||||
if (canUseOverviewCache && !bypassOverviewCache) {
|
||||
const cached = cachedCodeQueueOverview(overviewCacheKey);
|
||||
if (cached !== null) {
|
||||
recordOperationPerformance("frontend", "code_queue_direct_proxy_cache", 0, true, overviewCacheKey);
|
||||
@@ -733,6 +736,13 @@ async function proxyCodeQueueDirect(req: Request, url: URL): Promise<Response> {
|
||||
const responseHeaders = new Headers();
|
||||
const upstreamContentType = upstream.headers.get("content-type");
|
||||
if (upstreamContentType !== null) responseHeaders.set("content-type", upstreamContentType);
|
||||
if (isEventStreamRequest) {
|
||||
responseHeaders.set("cache-control", upstream.headers.get("cache-control") || "no-store, no-transform");
|
||||
responseHeaders.set("connection", "keep-alive");
|
||||
const buffering = upstream.headers.get("x-accel-buffering");
|
||||
if (buffering !== null) responseHeaders.set("x-accel-buffering", buffering);
|
||||
return new Response(upstream.body, { status: upstream.status, headers: responseHeaders });
|
||||
}
|
||||
const upstreamBody = await upstream.arrayBuffer();
|
||||
const isJsonResponse = (upstreamContentType ?? "").toLowerCase().includes("json");
|
||||
let parsedJson: unknown = null;
|
||||
@@ -786,7 +796,7 @@ async function proxyCodeQueueDirect(req: Request, url: URL): Promise<Response> {
|
||||
}
|
||||
if (expectsJsonResponse) {
|
||||
const text = jsonText;
|
||||
if (canUseOverviewCache && upstream.ok && typeof parsedJson === "object" && parsedJson !== null) {
|
||||
if (canUseOverviewCache && !bypassOverviewCache && upstream.ok && typeof parsedJson === "object" && parsedJson !== null) {
|
||||
codeQueueOverviewCache.set(codeQueueOverviewCacheKey(overviewCacheKey), { at: Date.now(), payload: parsedJson as JsonValue, text });
|
||||
}
|
||||
return new Response(text, { status: upstream.status, headers: responseHeaders });
|
||||
|
||||
@@ -129,11 +129,22 @@ function remoteOpenCodeRunCommand(task: QueueTask, prompt: string): string {
|
||||
`mkdir -p ${ctx().shellQuote(task.cwd)}`,
|
||||
`cd ${ctx().shellQuote(task.cwd)}`,
|
||||
envExports,
|
||||
`exec ${shellJoin(openCodeRunArgs(task, prompt))}`,
|
||||
`exec opencode ${shellJoin(openCodeRunArgs(task, prompt))}`,
|
||||
].join("; ");
|
||||
return `docker exec -i ${ctx().shellQuote(plan.containerName)} bash -lc ${ctx().shellQuote(inner)}`;
|
||||
}
|
||||
|
||||
export function remoteOpenCodeRunCommandForTest(task: QueueTask, prompt: string): string {
|
||||
return remoteOpenCodeRunCommand(task, prompt);
|
||||
}
|
||||
|
||||
export function openCodeTransportClosedBeforeTerminal(exitOk: boolean, hasFinalResponse: boolean, stepFinished: boolean): boolean {
|
||||
// Some OpenCode JSON streams can exit cleanly with visible assistant text but
|
||||
// without a step_finish event. Treat exit=0 plus a current final response as
|
||||
// terminal; otherwise the judge safety gate will retry a completed turn.
|
||||
return !exitOk || (!hasFinalResponse && !stepFinished);
|
||||
}
|
||||
|
||||
function openCodeSessionIdFromRecord(record: Record<string, unknown>, part: Record<string, unknown> | null): string | null {
|
||||
const top = ctx().recordStringField(record, ["sessionID", "sessionId", "session_id"]);
|
||||
if (top.length > 0) return top;
|
||||
@@ -326,7 +337,7 @@ async function runOpenCodeTurnOnce(task: QueueTask, prompt: string): Promise<Cod
|
||||
return {
|
||||
threadId: task.codexThreadId,
|
||||
turnId: null,
|
||||
finalResponse: task.finalResponse,
|
||||
finalResponse: "",
|
||||
terminalStatus: "failed",
|
||||
terminalError: message,
|
||||
transportClosedBeforeTerminal: true,
|
||||
@@ -350,7 +361,7 @@ async function runOpenCodeTurnOnce(task: QueueTask, prompt: string): Promise<Cod
|
||||
try {
|
||||
const exit = await app.closedPromise;
|
||||
clearInterval(activityWatchdog);
|
||||
const finalResponse = app.finalResponse || task.finalResponse;
|
||||
const finalResponse = app.finalResponse.trim().length > 0 ? app.finalResponse : "";
|
||||
const exitOk = exit.code === 0;
|
||||
const hasFinal = finalResponse.trim().length > 0;
|
||||
const status: TerminalStatus = exitOk && hasFinal ? "completed" : "failed";
|
||||
@@ -372,7 +383,7 @@ async function runOpenCodeTurnOnce(task: QueueTask, prompt: string): Promise<Cod
|
||||
finalResponse,
|
||||
terminalStatus: status,
|
||||
terminalError,
|
||||
transportClosedBeforeTerminal: !exitOk || !app.stepFinished,
|
||||
transportClosedBeforeTerminal: openCodeTransportClosedBeforeTerminal(exitOk, hasFinal, app.stepFinished),
|
||||
appServerExit: exit,
|
||||
events: app.events,
|
||||
};
|
||||
@@ -385,7 +396,7 @@ async function runOpenCodeTurnOnce(task: QueueTask, prompt: string): Promise<Cod
|
||||
return {
|
||||
threadId: app.sessionId ?? task.codexThreadId,
|
||||
turnId: app.runId,
|
||||
finalResponse: app.finalResponse || task.finalResponse,
|
||||
finalResponse: app.finalResponse.trim().length > 0 ? app.finalResponse : "",
|
||||
terminalStatus: "failed",
|
||||
terminalError: message,
|
||||
transportClosedBeforeTerminal: true,
|
||||
|
||||
@@ -26,7 +26,7 @@ export interface DevContainerContext {
|
||||
remoteContainerStartScript: (plan: DevContainerPlan, forceRecreate: boolean) => string;
|
||||
remoteHostWorkdirForTask: (task: QueueTask) => string;
|
||||
remoteKeyInstallScript: (plan: DevContainerPlan, privateKeyBase64: string) => string;
|
||||
runCodeQueueSsh: (providerId: string, script: string, timeoutMs: number, name: string) => DevContainerCommandLog;
|
||||
runCodeQueueSsh: (providerId: string, script: string, timeoutMs: number, name: string) => Promise<DevContainerCommandLog>;
|
||||
safePreview: (value: string, max?: number) => string;
|
||||
shellQuote: (value: string) => string;
|
||||
throwIfCommandFailed: (command: DevContainerCommandLog) => void;
|
||||
@@ -51,29 +51,29 @@ async function startDevContainerPlan(plan: DevContainerPlan, options: { forceRec
|
||||
verification?: Record<string, JsonValue>;
|
||||
}> {
|
||||
const commands: DevContainerCommandLog[] = [];
|
||||
const run = (targetProviderId: string, script: string, timeoutMs: number, name: string): DevContainerCommandLog => {
|
||||
const command = ctx().runCodeQueueSsh(targetProviderId, script, timeoutMs, name);
|
||||
const run = async (targetProviderId: string, script: string, timeoutMs: number, name: string): Promise<DevContainerCommandLog> => {
|
||||
const command = await ctx().runCodeQueueSsh(targetProviderId, script, timeoutMs, name);
|
||||
commands.push(command);
|
||||
ctx().throwIfCommandFailed(command);
|
||||
return command;
|
||||
};
|
||||
run("main-server", ctx().masterKeySetupScript(plan), 45_000, "master-key-setup");
|
||||
const keyRead = run("main-server", ctx().masterKeyReadScript(plan), 15_000, "master-key-read");
|
||||
await run("main-server", ctx().masterKeySetupScript(plan), 45_000, "master-key-setup");
|
||||
const keyRead = await run("main-server", ctx().masterKeyReadScript(plan), 15_000, "master-key-read");
|
||||
const keyBase64 = keyRead.stdout.trim();
|
||||
if (!/^[A-Za-z0-9+/=\r\n]+$/u.test(keyBase64) || keyBase64.length < 40) throw new Error("master key read returned invalid base64");
|
||||
keyRead.stdout = "[redacted private tunnel key]\n";
|
||||
run(plan.providerId, ctx().remoteKeyInstallScript(plan, keyBase64), 30_000, "remote-key-install");
|
||||
run(plan.providerId, ctx().remoteCodexConfigInstallScript(plan), 30_000, "remote-codex-config-install");
|
||||
run(plan.providerId, ctx().remoteContainerStartScript(plan, options.forceRecreate), 60_000, "remote-container-start");
|
||||
run("main-server", ctx().masterProxyPrepareScript(plan), 30_000, "master-proxy-prepare");
|
||||
run(plan.providerId, ctx().containerTunnelStartScript(plan), 45_000, "remote-tunnel-start");
|
||||
run("main-server", ctx().masterProxyFinishScript(plan), 30_000, "master-proxy-finish");
|
||||
if (options.prepareRuntime) run(plan.providerId, ctx().remoteCodexRuntimePrepareScript(plan), 180_000, "remote-codex-runtime-prepare");
|
||||
await run(plan.providerId, ctx().remoteKeyInstallScript(plan, keyBase64), 30_000, "remote-key-install");
|
||||
await run(plan.providerId, ctx().remoteCodexConfigInstallScript(plan), 30_000, "remote-codex-config-install");
|
||||
await run(plan.providerId, ctx().remoteContainerStartScript(plan, options.forceRecreate), 60_000, "remote-container-start");
|
||||
await run("main-server", ctx().masterProxyPrepareScript(plan), 30_000, "master-proxy-prepare");
|
||||
await run(plan.providerId, ctx().containerTunnelStartScript(plan), 45_000, "remote-tunnel-start");
|
||||
await run("main-server", ctx().masterProxyFinishScript(plan), 30_000, "master-proxy-finish");
|
||||
if (options.prepareRuntime) await run(plan.providerId, ctx().remoteCodexRuntimePrepareScript(plan), 600_000, "remote-codex-runtime-prepare");
|
||||
const verification: Record<string, JsonValue> = {};
|
||||
if (options.verifyPing) {
|
||||
const before = run("main-server", ctx().masterProxyEvidenceScript(plan), 15_000, "master-proxy-evidence-before-ping");
|
||||
const ping = run(plan.providerId, ctx().devContainerPingScript(plan), 20_000, "remote-container-ping-google");
|
||||
const after = run("main-server", ctx().masterProxyEvidenceScript(plan), 15_000, "master-proxy-evidence-after-ping");
|
||||
const before = await run("main-server", ctx().masterProxyEvidenceScript(plan), 15_000, "master-proxy-evidence-before-ping");
|
||||
const ping = await run(plan.providerId, ctx().devContainerPingScript(plan), 20_000, "remote-container-ping-google");
|
||||
const after = await run("main-server", ctx().masterProxyEvidenceScript(plan), 15_000, "master-proxy-evidence-after-ping");
|
||||
verification.pingGoogleOk = ping.exitCode === 0 && /0% packet loss|1 packets received|1 received/iu.test(ping.stdout);
|
||||
verification.directPingEvidence = commands.find((command) => command.name === "remote-tunnel-start")?.stdout.includes("direct_ping=failed_expected") === true
|
||||
? `direct ${plan.providerId} container ping failed before tunnel`
|
||||
@@ -175,8 +175,8 @@ docker inspect "$CONTAINER" --format 'container={{.Name}} state={{.State.Status}
|
||||
if docker inspect "$CONTAINER" >/dev/null 2>&1; then
|
||||
docker exec "$CONTAINER" bash -lc 'export PATH=/tmp/unidesk-tools:$PATH; echo default=$(ip route show default | head -1); echo resolv=$(tr "\\n" " " </etc/resolv.conf); echo pwd=$(pwd); command -v codex || true; ip addr show ${plan.tunName} 2>/dev/null || true' || true
|
||||
fi`;
|
||||
commands.push(ctx().runCodeQueueSsh(providerId, statusScript, 15_000, "remote-container-status"));
|
||||
commands.push(ctx().runCodeQueueSsh("main-server", ctx().masterProxyEvidenceScript(plan), 15_000, "master-proxy-status"));
|
||||
commands.push(await ctx().runCodeQueueSsh(providerId, statusScript, 15_000, "remote-container-status"));
|
||||
commands.push(await ctx().runCodeQueueSsh("main-server", ctx().masterProxyEvidenceScript(plan), 15_000, "master-proxy-status"));
|
||||
return ctx().jsonResponse({
|
||||
ok: commands.every((command) => command.exitCode === 0),
|
||||
providerId,
|
||||
|
||||
@@ -123,6 +123,8 @@ import {
|
||||
statsDaysFromUrl,
|
||||
taskForMetaResponse,
|
||||
taskForResponse,
|
||||
taskListStepCount,
|
||||
taskOutputMaxSeq as taskViewOutputMaxSeq,
|
||||
taskStatisticsSummary,
|
||||
taskSummaryResponse,
|
||||
timestampMs,
|
||||
@@ -148,10 +150,36 @@ const retryBackoffMaxMs = 10 * 60 * 1000;
|
||||
const queueIdPattern = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/u;
|
||||
const queueNameMaxLength = 80;
|
||||
const config = readConfig();
|
||||
|
||||
type CodeQueueEventType = "hello" | "task-updated" | "task-step" | "queue-updated";
|
||||
|
||||
interface CodeQueueEvent {
|
||||
sequence: number;
|
||||
type: CodeQueueEventType;
|
||||
at: string;
|
||||
reason: string;
|
||||
taskId?: string;
|
||||
queueId?: string;
|
||||
status?: TaskStatus;
|
||||
updatedAt?: string;
|
||||
stepCount?: number;
|
||||
outputMaxSeq?: number;
|
||||
}
|
||||
|
||||
interface CodeQueueEventClient {
|
||||
enqueue: (chunk: string) => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
const codeQueueEventClients = new Set<CodeQueueEventClient>();
|
||||
const codeQueueEventEncoder = new TextEncoder();
|
||||
const taskEventSnapshots = new Map<string, { stepCount: number; outputMaxSeq: number; status: TaskStatus; updatedAt: string }>();
|
||||
let codeQueueEventSequence = 0;
|
||||
const logger = createLogger("code-queue", config.logFile);
|
||||
const state = emptyState();
|
||||
let processing = false;
|
||||
const processingQueues = new Set<string>();
|
||||
const mergingQueues = new Set<string>();
|
||||
const activeRuns = new Map<string, ActiveRun>();
|
||||
const activeRunSlotReservations = new Set<string>();
|
||||
const activeRunSlotWaiters: ActiveRunSlotWaiter[] = [];
|
||||
@@ -582,6 +610,142 @@ function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function sseChunk(event: CodeQueueEvent): string {
|
||||
return [
|
||||
`id: ${event.sequence}`,
|
||||
`event: ${event.type}`,
|
||||
`data: ${JSON.stringify(event)}`,
|
||||
"",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function emitCodeQueueEvent(event: Omit<CodeQueueEvent, "sequence" | "at"> & { at?: string }): void {
|
||||
const payload: CodeQueueEvent = {
|
||||
...event,
|
||||
sequence: ++codeQueueEventSequence,
|
||||
at: event.at || nowIso(),
|
||||
};
|
||||
if (codeQueueEventClients.size === 0) return;
|
||||
const chunk = sseChunk(payload);
|
||||
for (const client of Array.from(codeQueueEventClients)) {
|
||||
try {
|
||||
client.enqueue(chunk);
|
||||
} catch {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function taskOutputMaxSeq(task: QueueTask): number {
|
||||
return taskViewOutputMaxSeq(task);
|
||||
}
|
||||
|
||||
function publishTaskEvent(task: QueueTask, reason: string, options: { onlyStepChange?: boolean; stepChanged?: boolean } = {}): void {
|
||||
// Hot path: never build full transcripts or read output archives here.
|
||||
const stepCount = taskListStepCount(task);
|
||||
const outputMaxSeq = taskOutputMaxSeq(task);
|
||||
const previous = taskEventSnapshots.get(task.id);
|
||||
const stepChanged = options.stepChanged ?? (previous === undefined ? stepCount > 0 : stepCount > previous.stepCount);
|
||||
if (options.onlyStepChange === true && !stepChanged) {
|
||||
taskEventSnapshots.set(task.id, { stepCount, outputMaxSeq, status: task.status, updatedAt: task.updatedAt });
|
||||
return;
|
||||
}
|
||||
const type: CodeQueueEventType = stepChanged ? "task-step" : "task-updated";
|
||||
taskEventSnapshots.set(task.id, { stepCount, outputMaxSeq, status: task.status, updatedAt: task.updatedAt });
|
||||
emitCodeQueueEvent({
|
||||
type,
|
||||
reason,
|
||||
taskId: task.id,
|
||||
queueId: queueIdOf(task),
|
||||
status: task.status,
|
||||
updatedAt: task.updatedAt,
|
||||
stepCount,
|
||||
outputMaxSeq,
|
||||
});
|
||||
}
|
||||
|
||||
function publishQueueEvent(reason: string, queueId = ""): void {
|
||||
emitCodeQueueEvent({ type: "queue-updated", reason, queueId: queueId || undefined });
|
||||
}
|
||||
|
||||
function outputCanChangeStepCount(output: LiveOutput): boolean {
|
||||
return output.channel === "command" || output.channel === "diff" || output.channel === "tool";
|
||||
}
|
||||
|
||||
function outputStartsTraceStep(output: LiveOutput): boolean {
|
||||
if (output.channel === "diff" || output.channel === "tool") return true;
|
||||
if (output.channel !== "command") return false;
|
||||
const method = String(output.method || "");
|
||||
return method === "item/started" || (method !== "item/commandExecution/outputDelta" && method !== "item/completed");
|
||||
}
|
||||
|
||||
function recordTaskOutputMetrics(task: QueueTask, output: LiveOutput, op: "set" | "append"): boolean {
|
||||
task.outputMaxSeq = Math.max(taskOutputMaxSeq(task), Number.isFinite(Number(output.seq)) ? Number(output.seq) : 0);
|
||||
if (op === "append" || !outputStartsTraceStep(output)) return false;
|
||||
const storedStepCount = Number(task.stepCount ?? task.llmStepCount);
|
||||
const nextStepCount = Number.isFinite(storedStepCount) && storedStepCount >= 0
|
||||
? Math.floor(storedStepCount) + 1
|
||||
: task.output.reduce((count, item) => count + (outputStartsTraceStep(item) ? 1 : 0), 0);
|
||||
task.stepCount = nextStepCount;
|
||||
task.llmStepCount = nextStepCount;
|
||||
return true;
|
||||
}
|
||||
|
||||
function codeQueueEventsResponse(): Response {
|
||||
let heartbeat: ReturnType<typeof setInterval> | null = null;
|
||||
let client: CodeQueueEventClient | null = null;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
client = {
|
||||
enqueue(chunk: string) {
|
||||
controller.enqueue(codeQueueEventEncoder.encode(chunk));
|
||||
},
|
||||
close() {
|
||||
if (heartbeat !== null) {
|
||||
clearInterval(heartbeat);
|
||||
heartbeat = null;
|
||||
}
|
||||
if (client !== null) codeQueueEventClients.delete(client);
|
||||
client = null;
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
// The browser may have already closed the EventSource connection.
|
||||
}
|
||||
},
|
||||
};
|
||||
codeQueueEventClients.add(client);
|
||||
controller.enqueue(codeQueueEventEncoder.encode("retry: 1000\n\n"));
|
||||
client.enqueue(sseChunk({
|
||||
sequence: codeQueueEventSequence,
|
||||
type: "hello",
|
||||
at: nowIso(),
|
||||
reason: "connected",
|
||||
}));
|
||||
heartbeat = setInterval(() => {
|
||||
try {
|
||||
controller.enqueue(codeQueueEventEncoder.encode(`: heartbeat ${nowIso()}\n\n`));
|
||||
} catch {
|
||||
client?.close();
|
||||
}
|
||||
}, 25_000);
|
||||
heartbeat.unref?.();
|
||||
},
|
||||
cancel() {
|
||||
client?.close();
|
||||
},
|
||||
});
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"content-type": "text/event-stream; charset=utf-8",
|
||||
"cache-control": "no-store, no-transform",
|
||||
"connection": "keep-alive",
|
||||
"x-accel-buffering": "no",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function resolveReasoningEffort(model: string, explicit?: string | null): string | null {
|
||||
const requested = explicit?.trim();
|
||||
if (requested) return requested;
|
||||
@@ -704,6 +868,15 @@ function pruneTaskHotState(task: QueueTask): void {
|
||||
}
|
||||
}
|
||||
|
||||
function taskRetainedTraceStepCount(task: QueueTask): number {
|
||||
return task.output.reduce((count, output) => count + (outputStartsTraceStep(output) ? 1 : 0), 0);
|
||||
}
|
||||
|
||||
function normalizeTaskMetric(value: unknown): number | null {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : null;
|
||||
}
|
||||
|
||||
function normalizeTask(task: QueueTask): QueueTask {
|
||||
task.queueId = safeQueueId(task.queueId);
|
||||
task.queueEnteredAt = taskQueueEnteredAt(task);
|
||||
@@ -726,6 +899,11 @@ function normalizeTask(task: QueueTask): QueueTask {
|
||||
task.judgeFailCount = Number.isInteger(task.judgeFailCount) && task.judgeFailCount >= 0 ? task.judgeFailCount : judgeFailCountFromOutput(task);
|
||||
const persistedPromptHistory = Array.isArray(task.promptHistory) ? task.promptHistory : [];
|
||||
task.promptHistory = mergePromptHistory([...persistedPromptHistory, ...outputPromptHistory(task)]);
|
||||
const storedStepCount = normalizeTaskMetric(task.stepCount) ?? normalizeTaskMetric(task.llmStepCount);
|
||||
const stepCount = storedStepCount ?? taskRetainedTraceStepCount(task);
|
||||
task.stepCount = stepCount;
|
||||
task.llmStepCount = stepCount;
|
||||
task.outputMaxSeq = Math.max(normalizeTaskMetric(task.outputMaxSeq) ?? 0, taskViewOutputMaxSeq(task));
|
||||
pruneTaskHotState(task);
|
||||
return task;
|
||||
}
|
||||
@@ -779,6 +957,7 @@ function markTaskDirty(taskId: string): void {
|
||||
function persistTaskState(task: QueueTask): void {
|
||||
markTaskDirty(task.id);
|
||||
persistState(false);
|
||||
publishTaskEvent(task, "persist");
|
||||
}
|
||||
|
||||
function markQueueDirty(queueId: string): void {
|
||||
@@ -815,14 +994,12 @@ function taskTimestamp(value: string | null): string | null {
|
||||
}
|
||||
|
||||
function lastOutputSeq(task: QueueTask): number {
|
||||
return task.output.at(-1)?.seq ?? 0;
|
||||
return taskOutputMaxSeq(task);
|
||||
}
|
||||
|
||||
function updateNextSeqFromTasks(): void {
|
||||
let nextSeq = Math.max(1, Number.isFinite(state.nextSeq) ? Math.floor(state.nextSeq) : 1);
|
||||
for (const task of state.tasks) {
|
||||
for (const output of task.output) nextSeq = Math.max(nextSeq, Number(output.seq) + 1);
|
||||
}
|
||||
for (const task of state.tasks) nextSeq = Math.max(nextSeq, lastOutputSeq(task) + 1);
|
||||
state.nextSeq = nextSeq;
|
||||
}
|
||||
|
||||
@@ -1603,6 +1780,7 @@ function addEvent(task: QueueTask, event: CodexEventSummary): void {
|
||||
task.events.push(event);
|
||||
if (config.maxInMemoryEventRecords > 0 && task.events.length > config.maxInMemoryEventRecords) task.events.splice(0, task.events.length - config.maxInMemoryEventRecords);
|
||||
markTaskDirty(task.id);
|
||||
publishTaskEvent(task, "agent-event", { onlyStepChange: true });
|
||||
}
|
||||
|
||||
function taskReferencesEqual(left: string[], right: string[]): boolean {
|
||||
@@ -1789,6 +1967,12 @@ configureTaskOutput({
|
||||
logger,
|
||||
markTaskDirty,
|
||||
nowIso,
|
||||
onOutputAppended: (task, output, op) => {
|
||||
const archiveOp = op === "append" ? "append" : "set";
|
||||
const stepChanged = recordTaskOutputMetrics(task, output, archiveOp);
|
||||
if (archiveOp === "append" && !outputCanChangeStepCount(output)) return;
|
||||
publishTaskEvent(task, "output", { onlyStepChange: archiveOp === "append", stepChanged });
|
||||
},
|
||||
schedulePersistState,
|
||||
});
|
||||
|
||||
@@ -2223,7 +2407,7 @@ async function runTask(task: QueueTask): Promise<void> {
|
||||
}
|
||||
const finishedAt = nowIso();
|
||||
task.finalResponse = result.finalResponse || task.finalResponse;
|
||||
task.attempts.push(attemptFromResult(task, mode, startedAt, finishedAt, result, attemptStartOutput?.seq ?? null, taskFullOutput(task).at(-1)?.seq ?? null, recoveryPrompt));
|
||||
task.attempts.push(attemptFromResult(task, mode, startedAt, finishedAt, result, attemptStartOutput?.seq ?? null, taskOutputMaxSeq(task), recoveryPrompt));
|
||||
task.status = "judging";
|
||||
task.activeTurnId = null;
|
||||
task.updatedAt = nowIso();
|
||||
@@ -2510,6 +2694,9 @@ function queuedStatusReason(task: QueueTask, tasks: QueueTask[] = state.tasks):
|
||||
if (!serviceReady) {
|
||||
return queuedReason("service", "SERVICE", "Code Queue is still starting and has not enabled scheduling yet.");
|
||||
}
|
||||
if (mergingQueues.has(queueId)) {
|
||||
return queuedReason("queue_merge", "MERGING", "Queue merge is rewriting queue membership; scheduling will resume immediately after it finishes.");
|
||||
}
|
||||
|
||||
const memoryPressure = activeRunMemoryPressure();
|
||||
if (memoryPressure !== null) {
|
||||
@@ -2592,6 +2779,7 @@ function scheduleQueue(queueId?: string): void {
|
||||
if (!serviceReady || shutdownRequested) return;
|
||||
const ids = queueId === undefined ? runnableQueueIds() : [queueId];
|
||||
for (const id of ids) {
|
||||
if (mergingQueues.has(id)) continue;
|
||||
if (processingQueues.has(id)) continue;
|
||||
void processQueue(id).catch((error) => {
|
||||
logger("error", "queue_loop_failed", { queueId: id, error: error instanceof Error ? error.stack ?? error.message : String(error) });
|
||||
@@ -2705,6 +2893,8 @@ function requestErrorResponse(error: unknown): Response | null {
|
||||
error.message === "request body must be an object"
|
||||
|| error.message === "prompt is required"
|
||||
|| error.message === "a task cannot reference itself while editing prompt"
|
||||
|| error.message === "sourceQueueId is required"
|
||||
|| error.message === "source queue must be different from target queue"
|
||||
|| error.message.startsWith("referenceTaskIds supports at most ")
|
||||
|| error.message.startsWith("queueId must match ")
|
||||
|| error.message.startsWith("queue name must be ")
|
||||
@@ -2738,6 +2928,8 @@ async function createTasks(req: Request): Promise<Response> {
|
||||
for (const task of tasks) appendOutput(task, "user", `${task.prompt}\n`, "enqueue");
|
||||
state.tasks.push(...tasks);
|
||||
if (tasks.length > 0) armIdleNotification();
|
||||
for (const task of tasks) publishTaskEvent(task, "enqueue");
|
||||
for (const id of new Set(tasks.map(queueIdOf))) publishQueueEvent("enqueue", id);
|
||||
persistState();
|
||||
logger("info", "tasks_enqueued", { count: tasks.length, ids: tasks.map((task) => task.id), queueIds: Array.from(new Set(tasks.map(queueIdOf))), providerIds: Array.from(new Set(tasks.map((task) => task.providerId))) });
|
||||
scheduleQueue();
|
||||
@@ -2865,6 +3057,7 @@ async function markTaskRead(task: QueueTask): Promise<Response> {
|
||||
task.readAt = nowIso();
|
||||
markTaskDirty(task.id);
|
||||
persistState(false);
|
||||
publishTaskEvent(task, "read");
|
||||
logger("info", "task_marked_read", { taskId: task.id, queueId: queueIdOf(task), status: task.status });
|
||||
}
|
||||
await flushDirtyTasksToDatabase(true);
|
||||
@@ -2907,7 +3100,10 @@ async function markTaskReadById(taskId: string): Promise<Response> {
|
||||
AND read_at IS NULL
|
||||
`;
|
||||
const hotTask = findTask(taskId);
|
||||
if (hotTask !== null) hotTask.readAt = readAt;
|
||||
if (hotTask !== null) {
|
||||
hotTask.readAt = readAt;
|
||||
publishTaskEvent(hotTask, "read");
|
||||
}
|
||||
logger("info", "task_marked_read", { taskId, queueId: safeQueueId(row.queue_id), status: row.status });
|
||||
}
|
||||
return compactJsonResponse({
|
||||
@@ -2952,6 +3148,7 @@ async function markTerminalTasksRead(url: URL): Promise<Response> {
|
||||
for (const task of state.tasks) {
|
||||
if (!ids.has(task.id)) continue;
|
||||
task.readAt = readAt;
|
||||
publishTaskEvent(task, "read-all");
|
||||
}
|
||||
if (ids.size > 0) {
|
||||
logger("info", "terminal_tasks_marked_read", { count: ids.size, queueId });
|
||||
@@ -2964,6 +3161,7 @@ async function markTerminalTasksRead(url: URL): Promise<Response> {
|
||||
if (!terminalTaskUnread(task)) continue;
|
||||
task.readAt = readAt;
|
||||
markTaskDirty(task.id);
|
||||
publishTaskEvent(task, "read-all");
|
||||
count += 1;
|
||||
}
|
||||
if (count > 0) {
|
||||
@@ -2983,6 +3181,7 @@ async function createQueue(req: Request): Promise<Response> {
|
||||
queue.updatedAt = nowIso();
|
||||
markQueueDirty(queue.id);
|
||||
persistState(false);
|
||||
publishQueueEvent("queue-created", queue.id);
|
||||
logger("info", "queue_created", { queueId, name: queue.name, existed: beforeCount === state.queues.length });
|
||||
await flushDirtyTasksToDatabase(true);
|
||||
const tasks = await loadAllTasksForRead();
|
||||
@@ -3003,12 +3202,154 @@ async function updateQueue(queueIdValue: string, req: Request): Promise<Response
|
||||
queue.updatedAt = nowIso();
|
||||
markQueueDirty(queue.id);
|
||||
persistState(false);
|
||||
publishQueueEvent("queue-updated", queue.id);
|
||||
logger("info", "queue_updated", { queueId, previousName, name: queue.name });
|
||||
await flushDirtyTasksToDatabase(true);
|
||||
const tasks = await loadAllTasksForRead();
|
||||
return jsonResponse({ ok: true, queue, queues: perQueueSummaries(tasks), summary: queueSummary(false, tasks) });
|
||||
}
|
||||
|
||||
function knownQueue(queueId: string): QueueRecord | null {
|
||||
return state.queues.find((queue) => queue.id === queueId) ?? null;
|
||||
}
|
||||
|
||||
function queueHasKnownTasks(queueId: string): boolean {
|
||||
return state.tasks.some((task) => queueIdOf(task) === queueId);
|
||||
}
|
||||
|
||||
function queueMergeBlocker(queueId: string): string | null {
|
||||
if (processingQueues.has(queueId)) return "queue processor is currently active";
|
||||
if (activeRuns.has(queueId)) return "queue has an active agent run";
|
||||
if (activeRunSlotReservations.has(queueId)) return "queue is reserving an active run slot";
|
||||
if (activeRunSlotWaiters.some((waiter) => waiter.queueId === queueId)) return "queue is waiting for an active run slot";
|
||||
const activeTask = state.tasks.find((task) => queueIdOf(task) === queueId && (task.status === "running" || task.status === "judging"));
|
||||
return activeTask === undefined ? null : `task ${activeTask.id} is ${activeTask.status}`;
|
||||
}
|
||||
|
||||
function parseSourceQueueIds(record: Record<string, unknown>, targetQueueId: string): string[] {
|
||||
const raw = record.sourceQueueIds ?? record.sources ?? record.sourceQueueId ?? record.fromQueueId ?? record.from ?? record.queueId ?? record.id;
|
||||
const values = Array.isArray(raw)
|
||||
? raw
|
||||
: typeof raw === "string"
|
||||
? raw.split(/[,\s]+/u)
|
||||
: [];
|
||||
const ids: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const value of values) {
|
||||
const id = normalizeQueueId(value);
|
||||
if (id === targetQueueId) throw new Error("source queue must be different from target queue");
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
ids.push(id);
|
||||
}
|
||||
if (ids.length === 0) throw new Error("sourceQueueId is required");
|
||||
return ids;
|
||||
}
|
||||
|
||||
async function mergeDatabaseQueueTasks(sourceQueueIds: string[], targetQueueId: string): Promise<string[]> {
|
||||
if (!databaseReady || sourceQueueIds.length === 0) return [];
|
||||
const rows = await sql<Array<{ id: string }>>`
|
||||
UPDATE unidesk_code_queue_tasks
|
||||
SET
|
||||
queue_id = ${targetQueueId},
|
||||
task_json = jsonb_set(
|
||||
jsonb_set(
|
||||
task_json,
|
||||
'{queueId}',
|
||||
to_jsonb(${targetQueueId}::text),
|
||||
true
|
||||
),
|
||||
'{queueEnteredAt}',
|
||||
to_jsonb(COALESCE(NULLIF(task_json->>'queueEnteredAt', ''), to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'))::text),
|
||||
true
|
||||
)
|
||||
WHERE queue_id IN ${sql(sourceQueueIds)}
|
||||
RETURNING id
|
||||
`;
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
|
||||
async function mergeQueues(targetQueueIdValue: string | null, req: Request): Promise<Response> {
|
||||
const body = await readJson(req);
|
||||
const record = typeof body === "object" && body !== null && !Array.isArray(body) ? body as Record<string, unknown> : {};
|
||||
const targetQueueId = normalizeQueueId(targetQueueIdValue ?? record.targetQueueId ?? record.intoQueueId ?? record.into);
|
||||
const sourceQueueIds = parseSourceQueueIds(record, targetQueueId);
|
||||
const missingSources = sourceQueueIds.filter((id) => knownQueue(id) === null && !queueHasKnownTasks(id));
|
||||
if (missingSources.length > 0) return jsonResponse({ ok: false, error: `source queue not found: ${missingSources.join(", ")}` }, 404);
|
||||
|
||||
const mergeQueueIds = Array.from(new Set([targetQueueId, ...sourceQueueIds]));
|
||||
for (const id of mergeQueueIds) mergingQueues.add(id);
|
||||
try {
|
||||
for (const id of mergeQueueIds) {
|
||||
const blocker = queueMergeBlocker(id);
|
||||
if (blocker !== null) {
|
||||
return jsonResponse({ ok: false, error: `cannot merge queue ${id}: ${blocker}` }, 409);
|
||||
}
|
||||
}
|
||||
|
||||
const mergedAt = nowIso();
|
||||
const targetQueue = ensureQueue(targetQueueId);
|
||||
const sourceQueues = sourceQueueIds.map((id) => ensureQueue(id));
|
||||
targetQueue.updatedAt = mergedAt;
|
||||
markQueueDirty(targetQueue.id);
|
||||
for (const queue of sourceQueues) {
|
||||
queue.updatedAt = mergedAt;
|
||||
markQueueDirty(queue.id);
|
||||
}
|
||||
|
||||
const sourceSet = new Set(sourceQueueIds);
|
||||
const hotMovedTasks: QueueTask[] = [];
|
||||
for (const task of state.tasks) {
|
||||
const previousQueueId = queueIdOf(task);
|
||||
if (!sourceSet.has(previousQueueId)) continue;
|
||||
task.queueEnteredAt = taskQueueEnteredAt(task);
|
||||
task.queueId = targetQueueId;
|
||||
hotMovedTasks.push(task);
|
||||
markTaskDirty(task.id);
|
||||
publishTaskEvent(task, "queue-merged");
|
||||
}
|
||||
const databaseMovedTaskIds = await mergeDatabaseQueueTasks(sourceQueueIds, targetQueueId);
|
||||
persistState(false);
|
||||
publishQueueEvent("queue-merged", targetQueueId);
|
||||
for (const sourceQueueId of sourceQueueIds) publishQueueEvent("queue-merged", sourceQueueId);
|
||||
if (hotMovedTasks.some((task) => task.status === "queued" || task.status === "retry_wait")) armIdleNotification();
|
||||
logger("info", "queues_merged", {
|
||||
targetQueueId,
|
||||
sourceQueueIds,
|
||||
hotMovedTaskCount: hotMovedTasks.length,
|
||||
databaseMovedTaskCount: databaseReady ? databaseMovedTaskIds.length : null,
|
||||
});
|
||||
for (const id of mergeQueueIds) mergingQueues.delete(id);
|
||||
scheduleQueue(targetQueueId);
|
||||
await flushDirtyTasksToDatabase(true);
|
||||
const tasks = await loadAllTasksForRead();
|
||||
const movedIdSet = new Set(databaseReady ? databaseMovedTaskIds : hotMovedTasks.map((task) => task.id));
|
||||
const orderedMovedTaskIds = tasks
|
||||
.filter((task) => movedIdSet.has(task.id))
|
||||
.sort(compareTaskQueueOrder)
|
||||
.map((task) => task.id);
|
||||
const targetTaskOrder = tasks
|
||||
.filter((task) => queueIdOf(task) === targetQueueId)
|
||||
.sort(compareTaskQueueOrder)
|
||||
.map((task) => ({ id: task.id, queueEnteredAt: taskQueueEnteredAt(task), createdAt: task.createdAt, status: task.status }));
|
||||
return jsonResponse({
|
||||
ok: true,
|
||||
targetQueueId,
|
||||
sourceQueueIds,
|
||||
mergedTaskCount: databaseReady ? databaseMovedTaskIds.length : hotMovedTasks.length,
|
||||
movedTaskIds: orderedMovedTaskIds.slice(0, 500),
|
||||
targetTaskOrder: targetTaskOrder.slice(0, 500),
|
||||
order: "merged tasks keep their original queueEnteredAt/createdAt ordering; source queue records are retained and never deleted",
|
||||
targetQueue,
|
||||
sourceQueues,
|
||||
queues: perQueueSummaries(tasks),
|
||||
summary: queueSummary(false, tasks),
|
||||
}, 202);
|
||||
} finally {
|
||||
for (const id of mergeQueueIds) mergingQueues.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
async function moveTaskToQueue(task: QueueTask, req: Request): Promise<Response> {
|
||||
if (task.status === "running" || task.status === "judging") {
|
||||
return jsonResponse({ ok: false, error: `cannot move active task ${task.id} while status=${task.status}`, task: taskForResponse(task) }, 409);
|
||||
@@ -3027,6 +3368,8 @@ async function moveTaskToQueue(task: QueueTask, req: Request): Promise<Response>
|
||||
appendOutput(task, "system", `moved from queue=${previousQueueId} to queue=${queueId}\n`, "queue/move");
|
||||
if (task.status === "queued" || task.status === "retry_wait") armIdleNotification();
|
||||
persistState();
|
||||
publishQueueEvent("task-moved", previousQueueId);
|
||||
publishQueueEvent("task-moved", queueId);
|
||||
logger("info", "task_moved_queue", { taskId: task.id, previousQueueId, queueId, status: task.status });
|
||||
if (task.status === "queued" || task.status === "retry_wait") scheduleQueue(queueId);
|
||||
await flushDirtyTasksToDatabase(true);
|
||||
@@ -3039,6 +3382,7 @@ async function route(req: Request): Promise<Response> {
|
||||
try {
|
||||
if (url.pathname === "/" || url.pathname === "/health") return jsonResponse({ ok: true, service: "code-queue", queue: await queueSummaryForHealth(), startedAt: serviceStartedAt });
|
||||
if (url.pathname === "/logs") return jsonResponse({ ok: true, logs: recentLogs.slice(-parseLimit(url)) });
|
||||
if (url.pathname === "/api/events" && req.method === "GET") return codeQueueEventsResponse();
|
||||
if (url.pathname === "/api/dev-ready" && req.method === "GET") return jsonResponse({ ok: true, devReady: collectDevReady() });
|
||||
if (url.pathname === "/api/dev-containers" && req.method === "GET") {
|
||||
const plan = buildDevContainerPlan(normalizeProviderId(config.devContainerDefaultProviderId) ?? "D601", {});
|
||||
@@ -3091,6 +3435,9 @@ async function route(req: Request): Promise<Response> {
|
||||
return jsonResponse({ ok: true, queues: perQueueSummaries(tasks), queue: queueSummary(false, tasks) });
|
||||
}
|
||||
if (url.pathname === "/api/queues" && req.method === "POST") return await createQueue(req);
|
||||
if (url.pathname === "/api/queues/merge" && req.method === "POST") return await mergeQueues(null, req);
|
||||
const queueMergeMatch = url.pathname.match(/^\/api\/queues\/([^/]+)\/merge$/u);
|
||||
if (queueMergeMatch !== null && req.method === "POST") return await mergeQueues(decodeURIComponent(queueMergeMatch[1] ?? ""), req);
|
||||
const queueMatch = url.pathname.match(/^\/api\/queues\/([^/]+)$/u);
|
||||
if (queueMatch !== null && (req.method === "PATCH" || req.method === "PUT" || req.method === "POST")) return await updateQueue(decodeURIComponent(queueMatch[1] ?? ""), req);
|
||||
if (url.pathname === "/api/tasks/read-all" && req.method === "POST") return await markTerminalTasksRead(url);
|
||||
|
||||
@@ -20,6 +20,23 @@ export const defaultJudgeProbeCases: JudgeProbeCase[] = [
|
||||
],
|
||||
events: [{ at: nowIso(), method: "turn/completed", status: "completed" }],
|
||||
},
|
||||
{
|
||||
id: "completed_with_tool_evidence_but_no_current_final_response_should_retry",
|
||||
prompt: "调查 /mnt/f/Work/ConStart/docs/MDTODO 的最新进展,只读,并在最终回复中总结。",
|
||||
finalResponse: "",
|
||||
expected: "retry",
|
||||
expectedContinuePromptIncludes: [
|
||||
"最终 assistant response",
|
||||
"不能只执行工具",
|
||||
],
|
||||
terminalStatus: "completed",
|
||||
outputs: [
|
||||
{ channel: "user", text: "调查 /mnt/f/Work/ConStart/docs/MDTODO 的最新进展,只读,并在最终回复中总结。\n", method: "enqueue" },
|
||||
{ channel: "tool", text: "opencode/tool: read /mnt/f/Work/ConStart/docs/MDTODO/20260419_频率判断.md status=completed; R1/R2 completed, R3 pending", method: "opencode/tool" },
|
||||
{ channel: "system", text: "opencode completed status=completed exit=0 signal=null\n", method: "opencode/complete" },
|
||||
],
|
||||
events: [{ at: nowIso(), method: "opencode/step_finish", status: "completed" }],
|
||||
},
|
||||
{
|
||||
id: "completed_but_plan_only",
|
||||
prompt: "Create /root/unidesk/tmp/judge_probe.txt containing exactly judge-probe-ok, then summarize the file path.",
|
||||
|
||||
@@ -588,20 +588,63 @@ function originalRequirementOnlyFeedbackPrompt(task: QueueTask, reason: string):
|
||||
return `未完成原始需求:${originalTask || "原始任务尚未完成"}。继续按照原始需求完成剩余任务。`;
|
||||
}
|
||||
|
||||
function applyFallbackSafetyOverrides(task: QueueTask, result: CodexRunResult, judge: JudgeResult): JudgeResult {
|
||||
// Non-LLM string/regex overrides are a last-resort fallback only. Never call
|
||||
// this on a successful MiniMax judge result; MiniMax is the authoritative judge.
|
||||
if (judge.source !== "fallback") return judge;
|
||||
function missingFinalResponseFeedbackPrompt(task: QueueTask, reason: string): string {
|
||||
return [
|
||||
retryInstruction,
|
||||
`上一次 attempt 未完成:${judgeReasonForPrompt(reason)}`,
|
||||
"本轮必须在最终 assistant response 中给出可见交付结果;不能只执行工具、输出 reasoning 或依赖旧 finalResponse。",
|
||||
"原始任务摘要/按需查询:",
|
||||
compactRetryTaskContext(task),
|
||||
].join("\n\n");
|
||||
}
|
||||
|
||||
function applyJudgeSafetyOverrides(task: QueueTask, result: CodexRunResult, judge: JudgeResult): JudgeResult {
|
||||
const currentFinalText = result.finalResponse || "";
|
||||
if (judge.decision === "retry" && rateLimitFeedbackNeedsOriginalRequirementOnly(task, result)) {
|
||||
const reason = judge.reason || "任务因限流/传输中断,原始需求尚未完成。";
|
||||
if (result.terminalStatus === "interrupted" && explicitUserInterrupt(task, result)) {
|
||||
return {
|
||||
...judge,
|
||||
decision: "fail",
|
||||
confidence: Math.max(judge.confidence, 0.9),
|
||||
reason: "Codex turn 被用户请求打断。",
|
||||
raw: { previous: judge.raw ?? null, _safetyOverride: "explicit_user_interrupt" },
|
||||
};
|
||||
}
|
||||
if (rateLimitFeedbackNeedsOriginalRequirementOnly(task, result)) {
|
||||
const reason = judge.reason || result.terminalError || "任务因限流/传输中断,原始需求尚未完成。";
|
||||
return {
|
||||
...judge,
|
||||
decision: "retry",
|
||||
confidence: Math.max(judge.confidence, 0.9),
|
||||
reason,
|
||||
continuePrompt: originalRequirementOnlyFeedbackPrompt(task, reason),
|
||||
raw: { previous: judge.raw ?? null, _safetyOverride: "original_requirement_only_feedback" },
|
||||
};
|
||||
}
|
||||
if (result.terminalStatus === "interrupted") return judge;
|
||||
if (result.terminalStatus === "failed" || result.terminalStatus === null || result.transportClosedBeforeTerminal) {
|
||||
const reason = result.terminalError
|
||||
? `当前 attempt 未完成:${result.terminalError}`
|
||||
: "当前 attempt 未正常完成。";
|
||||
return {
|
||||
...judge,
|
||||
decision: "retry",
|
||||
confidence: Math.max(judge.confidence, 0.9),
|
||||
reason,
|
||||
continuePrompt: missingFinalResponseFeedbackPrompt(task, reason),
|
||||
raw: { previous: judge.raw ?? null, _safetyOverride: "terminal_not_completed" },
|
||||
};
|
||||
}
|
||||
if (result.finalResponse.trim().length === 0) {
|
||||
const reason = "当前 attempt 没有生成新的最终 assistant response,不能复用旧 finalResponse 判定完成。";
|
||||
return {
|
||||
...judge,
|
||||
decision: "retry",
|
||||
confidence: Math.max(judge.confidence, 0.92),
|
||||
reason,
|
||||
continuePrompt: missingFinalResponseFeedbackPrompt(task, reason),
|
||||
raw: { previous: judge.raw ?? null, _safetyOverride: "missing_current_final_response" },
|
||||
};
|
||||
}
|
||||
if (asksToConfirmConcurrentFileInsteadOfDelivery(currentFinalText)) {
|
||||
const reason = "最终回复停下来询问用户如何处理并发修改/无关文件,而不是自主完成自己的变更交付范围。";
|
||||
return {
|
||||
@@ -628,7 +671,7 @@ function applyFallbackSafetyOverrides(task: QueueTask, result: CodexRunResult, j
|
||||
}
|
||||
|
||||
export async function judgeTask(task: QueueTask, result: CodexRunResult): Promise<JudgeResult> {
|
||||
if (config().minimaxApiKey.length === 0) return applyFallbackSafetyOverrides(task, result, fallbackJudge(result));
|
||||
if (config().minimaxApiKey.length === 0) return applyJudgeSafetyOverrides(task, result, fallbackJudge(result));
|
||||
const judgePromptContent = judgePrompt(task, result);
|
||||
try {
|
||||
let lastParseError: string | null = null;
|
||||
@@ -654,9 +697,7 @@ export async function judgeTask(task: QueueTask, result: CodexRunResult): Promis
|
||||
source: "minimax",
|
||||
raw: { ...(parsed as Record<string, JsonValue>), _parseSource: parsedResult.source, _repairAttempt: repairAttempt },
|
||||
};
|
||||
// No local hard-gate validation or safety override is applied to a
|
||||
// successful MiniMax result. Fallback rules are only for MiniMax failure.
|
||||
return judge;
|
||||
return applyJudgeSafetyOverrides(task, result, judge);
|
||||
} catch (error) {
|
||||
lastParseError = error instanceof Error ? error.message : String(error);
|
||||
if (repairAttempt >= config().judgeRepairAttempts) throw new Error(lastParseError);
|
||||
@@ -674,7 +715,7 @@ export async function judgeTask(task: QueueTask, result: CodexRunResult): Promis
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger("warn", "judge_failed_fallback", { taskId: task.id, error: message });
|
||||
return applyFallbackSafetyOverrides(task, result, fallbackJudge(result, message));
|
||||
return applyJudgeSafetyOverrides(task, result, fallbackJudge(result, message));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 重构前 index.ts 只读参考:commit 6a04144d3f5103014f75b637d7e6bc2f45bf007f,blob 56e590c1a6b5ca7ad128bf2c992f60e46c355a58;可用 `git show 6a04144d3f5103014f75b637d7e6bc2f45bf007f:src/components/microservices/code-queue/src/index.ts` 查看。
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { opencodeNpmPackage } from "./code-agent/common";
|
||||
@@ -124,27 +124,66 @@ function buildDevContainerPlan(providerId: string, body: Record<string, unknown>
|
||||
};
|
||||
}
|
||||
|
||||
function runCodeQueueSsh(providerId: string, script: string, timeoutMs: number, name: string): DevContainerCommandLog {
|
||||
function appendLimited(buffer: string, chunk: Buffer | string, maxBytes: number): { value: string; truncated: boolean } {
|
||||
const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
||||
if (buffer.length >= maxBytes) return { value: buffer, truncated: true };
|
||||
const remaining = maxBytes - buffer.length;
|
||||
if (text.length <= remaining) return { value: buffer + text, truncated: false };
|
||||
return { value: buffer + text.slice(0, remaining), truncated: true };
|
||||
}
|
||||
|
||||
function runCodeQueueSsh(providerId: string, script: string, timeoutMs: number, name: string): Promise<DevContainerCommandLog> {
|
||||
const started = Date.now();
|
||||
const result = spawnSync("bun", ["scripts/cli.ts", "ssh", providerId, "bash -s"], {
|
||||
cwd: ctx().config.defaultWorkdir,
|
||||
input: script,
|
||||
encoding: "utf8",
|
||||
timeout: timeoutMs,
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
const maxBuffer = 8 * 1024 * 1024;
|
||||
return new Promise((resolveLog) => {
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let truncated = false;
|
||||
let timedOut = false;
|
||||
let spawnError: Error | null = null;
|
||||
const child = spawn("bun", ["scripts/cli.ts", "ssh", providerId, "bash -s"], {
|
||||
cwd: ctx().config.defaultWorkdir,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill("SIGTERM");
|
||||
setTimeout(() => child.kill("SIGKILL"), 2_000).unref?.();
|
||||
}, timeoutMs);
|
||||
timer.unref?.();
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
const next = appendLimited(stdout, chunk, maxBuffer);
|
||||
stdout = next.value;
|
||||
truncated ||= next.truncated;
|
||||
});
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
const next = appendLimited(stderr, chunk, maxBuffer);
|
||||
stderr = next.value;
|
||||
truncated ||= next.truncated;
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
spawnError = error;
|
||||
});
|
||||
child.stdin.on("error", () => undefined);
|
||||
child.on("close", (code, signal) => {
|
||||
clearTimeout(timer);
|
||||
const errorParts: string[] = [];
|
||||
if (stderr.length > 0) errorParts.push(stderr);
|
||||
if (timedOut) errorParts.push(`Error: command timed out after ${timeoutMs}ms`);
|
||||
if (spawnError !== null) errorParts.push(`${spawnError.name}: ${spawnError.message}`);
|
||||
if (truncated) errorParts.push(`output truncated at ${maxBuffer} bytes`);
|
||||
resolveLog({
|
||||
name,
|
||||
providerId,
|
||||
exitCode: timedOut && code === 0 ? null : code,
|
||||
signal: signal as NodeJS.Signals | null,
|
||||
durationMs: Date.now() - started,
|
||||
stdout,
|
||||
stderr: errorParts.join("\n").trim(),
|
||||
});
|
||||
});
|
||||
child.stdin.end(script);
|
||||
});
|
||||
const stdout = typeof result.stdout === "string" ? result.stdout : String(result.stdout ?? "");
|
||||
const stderr = typeof result.stderr === "string" ? result.stderr : String(result.stderr ?? "");
|
||||
const errorText = result.error === undefined ? "" : `${result.error.name}: ${result.error.message}`;
|
||||
return {
|
||||
name,
|
||||
providerId,
|
||||
exitCode: result.status,
|
||||
signal: result.signal,
|
||||
durationMs: Date.now() - started,
|
||||
stdout,
|
||||
stderr: errorText.length > 0 ? `${stderr}\n${errorText}`.trim() : stderr,
|
||||
};
|
||||
}
|
||||
|
||||
function throwIfCommandFailed(command: DevContainerCommandLog): void {
|
||||
@@ -246,6 +285,8 @@ IMAGE="$REQUESTED_IMAGE"
|
||||
SSH_DIR="\${UNIDESK_HOST_ROOT_SSH_DIR:-$HOME/.ssh}"
|
||||
SSH_MOUNT_ARGS=()
|
||||
if [ -d "$SSH_DIR" ]; then SSH_MOUNT_ARGS=(-v "$SSH_DIR":/root/.ssh:ro); fi
|
||||
HOST_PATH_MOUNT_ARGS=()
|
||||
if [ -d /mnt ]; then HOST_PATH_MOUNT_ARGS+=(-v /mnt:/mnt); fi
|
||||
if ! docker image inspect "$IMAGE" >/dev/null 2>&1 && docker image inspect "$CODEX_IMAGE" >/dev/null 2>&1; then
|
||||
IMAGE="$CODEX_IMAGE"
|
||||
fi
|
||||
@@ -295,6 +336,7 @@ cid=$(docker run -d \\
|
||||
-v "$KEY_DIR/codex-home":${shellQuote(plan.remoteCodexHome)} \\
|
||||
-v "$KEY_DIR/opencode-xdg":${shellQuote(plan.remoteOpencodeXdgDir)} \\
|
||||
"\${SSH_MOUNT_ARGS[@]}" \\
|
||||
"\${HOST_PATH_MOUNT_ARGS[@]}" \\
|
||||
-v "$WORKDIR":"$CONTAINER_WORKDIR" \\
|
||||
-v "$WORKDIR":/root/unidesk \\
|
||||
-w "$CONTAINER_WORKDIR" \\
|
||||
@@ -341,14 +383,45 @@ KNOWN_HOSTS=/tmp/unidesk-dev-proxy-known_hosts
|
||||
cp /run/unidesk-dev-proxy/known_hosts "$KNOWN_HOSTS"
|
||||
chmod 600 "$KNOWN_HOSTS"
|
||||
DEFAULT_LINE=$(ip route show default | head -1)
|
||||
ORIG_GW=$(printf '%s\\n' "$DEFAULT_LINE" | sed -n 's/^default via \\([^ ]*\\) dev \\([^ ]*\\).*/\\1/p')
|
||||
ORIG_DEV=$(printf '%s\\n' "$DEFAULT_LINE" | sed -n 's/^default via \\([^ ]*\\) dev \\([^ ]*\\).*/\\2/p')
|
||||
if [ -z "$ORIG_GW" ] || [ -z "$ORIG_DEV" ]; then echo "cannot parse default route: $DEFAULT_LINE" >&2; exit 1; fi
|
||||
DEFAULT_GW=$(printf '%s\\n' "$DEFAULT_LINE" | sed -n 's/^default via \\([^ ]*\\) dev \\([^ ]*\\).*/\\1/p')
|
||||
DEFAULT_DEV=$(printf '%s\\n' "$DEFAULT_LINE" | sed -n 's/^default via \\([^ ]*\\) dev \\([^ ]*\\).*/\\2/p')
|
||||
LINK_ROUTE=$(ip route show | awk '$2=="dev" && $3 !~ /^tun/ { print; exit }')
|
||||
ORIG_DEV=$(printf '%s\\n' "$LINK_ROUTE" | awk '{print $3}')
|
||||
if [ -n "$DEFAULT_GW" ] && [ -n "$DEFAULT_DEV" ] && [ "$DEFAULT_DEV" != "$TUN" ]; then
|
||||
ORIG_GW="$DEFAULT_GW"
|
||||
ORIG_DEV="$DEFAULT_DEV"
|
||||
else
|
||||
ORIG_IP=$(ip addr show "$ORIG_DEV" | sed -n 's/.*inet \\([0-9.]*\\)\\/.*/\\1/p' | head -1)
|
||||
ORIG_GW=$(printf '%s\\n' "$ORIG_IP" | awk -F. 'NF==4 { printf "%s.%s.%s.1", $1, $2, $3 }')
|
||||
fi
|
||||
if [ -z "$ORIG_GW" ] || [ -z "$ORIG_DEV" ]; then echo "cannot detect docker bridge route: default=$DEFAULT_LINE link=$LINK_ROUTE" >&2; exit 1; fi
|
||||
( ping -c 1 -W 3 google.com >/tmp/direct-ping.log 2>&1 && echo direct_ping=unexpected_ok ) || echo direct_ping=failed_expected
|
||||
if command -v pkill >/dev/null 2>&1; then
|
||||
pkill -f "ssh .* -w $TUN_ID:$TUN_ID .*$MASTER" >/dev/null 2>&1 || true
|
||||
elif command -v busybox >/dev/null 2>&1; then
|
||||
busybox ps | while read -r pid user command args; do
|
||||
case "$command $args" in
|
||||
*"ssh -f -N -w $TUN_ID:$TUN_ID"*|*"ssh -N -w $TUN_ID:$TUN_ID"*) kill "$pid" >/dev/null 2>&1 || true ;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
ip link delete "$TUN" >/dev/null 2>&1 || true
|
||||
ip route replace $MASTER/32 via "$ORIG_GW" dev "$ORIG_DEV"
|
||||
if command -v pkill >/dev/null 2>&1; then pkill -f "ssh .* -w $TUN_ID:$TUN_ID .*$MASTER" >/dev/null 2>&1 || true; fi
|
||||
ssh -f -N -w $TUN_ID:$TUN_ID -o Tunnel=point-to-point -o ExitOnForwardFailure=yes -o ServerAliveInterval=15 -o ServerAliveCountMax=2 -o StrictHostKeyChecking=yes -o UserKnownHostsFile="$KNOWN_HOSTS" -i /run/unidesk-dev-proxy/id_ed25519 root@$MASTER
|
||||
for i in 1 2 3 4 5; do ip link show "$TUN" >/dev/null 2>&1 && break; sleep 1; done
|
||||
ip route replace default via "$ORIG_GW" dev "$ORIG_DEV"
|
||||
SSH_LOG=/tmp/unidesk-dev-proxy-ssh.log
|
||||
rm -f "$SSH_LOG"
|
||||
if ! ssh -f -N -w $TUN_ID:$TUN_ID -o Tunnel=point-to-point -o ExitOnForwardFailure=yes -o ServerAliveInterval=15 -o ServerAliveCountMax=2 -o StrictHostKeyChecking=yes -o UserKnownHostsFile="$KNOWN_HOSTS" -i /run/unidesk-dev-proxy/id_ed25519 root@$MASTER 2>"$SSH_LOG"; then
|
||||
cat "$SSH_LOG" >&2 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
for i in 1 2 3 4 5 6 7 8 9 10; do ip link show "$TUN" >/dev/null 2>&1 && break; sleep 1; done
|
||||
if ! ip link show "$TUN" >/dev/null 2>&1; then
|
||||
echo "ssh tunnel did not create $TUN via $ORIG_GW/$ORIG_DEV" >&2
|
||||
cat "$SSH_LOG" >&2 2>/dev/null || true
|
||||
if command -v busybox >/dev/null 2>&1; then busybox ps >&2 || true; fi
|
||||
ip route show >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
ip addr replace $CLIENT_IP peer $SERVER_IP dev "$TUN"
|
||||
ip link set "$TUN" up
|
||||
ip route replace default via $SERVER_IP dev "$TUN"
|
||||
|
||||
@@ -5,7 +5,7 @@ import { codeAgentPortForModel, codeAgentPortInfo, codeModelPorts as codeModelPo
|
||||
import { claudeQqNotificationOutboxStats, notificationTargetConfigured, notificationTargetLabel } from "./notifications";
|
||||
import { executionProviderOptions } from "./provider-runtime";
|
||||
import { taskFullOutput } from "./task-output";
|
||||
import { buildCompactTaskTranscript, buildTaskTranscript, cachedPreviewTranscript, fullTranscript, prefixPreview, safePreview, statsDaysFromUrl, taskForCompactMetaResponse, taskForMetaResponse, taskLlmStepCount, taskStatisticsSummary, taskTiming, timestampMs } from "./task-view";
|
||||
import { buildCompactTaskTranscript, buildTaskTranscript, cachedPreviewTranscript, fullTranscript, prefixPreview, safePreview, statsDaysFromUrl, taskForCompactMetaResponse, taskForMetaResponse, taskListStepCount, taskStatisticsSummary, taskTiming, timestampMs } from "./task-view";
|
||||
import { userPromptForDisplay } from "./prompts";
|
||||
import type { ActiveRun, ActiveRunSlotWaiter } from "./code-agent/common";
|
||||
import type { JsonValue, QueueRecord, QueuedStatusReason, QueueTask, RuntimeConfig, TaskStatus, TranscriptLine } from "./types";
|
||||
@@ -143,7 +143,7 @@ function outputChunkResponse(task: QueueTask, url: URL): Response {
|
||||
function taskForListResponse(task: QueueTask, lite = false, queueTasks?: QueueTask[]): JsonValue {
|
||||
const timing = taskTiming(task);
|
||||
const displayPrompt = task.basePrompt || userPromptForDisplay(task.prompt);
|
||||
const stepCount = taskLlmStepCount(task);
|
||||
const stepCount = taskListStepCount(task);
|
||||
if (lite) {
|
||||
return {
|
||||
id: task.id,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// 重构前 index.ts 只读参考:commit 6a04144d3f5103014f75b637d7e6bc2f45bf007f,blob 56e590c1a6b5ca7ad128bf2c992f60e46c355a58;可用 `git show 6a04144d3f5103014f75b637d7e6bc2f45bf007f:src/components/microservices/code-queue/src/index.ts` 查看。
|
||||
|
||||
import { minimaxM27Model } from "./code-agent/common";
|
||||
import { openCodeTransportClosedBeforeTerminal, remoteOpenCodeRunCommandForTest } from "./code-agent/opencode";
|
||||
import { continuePromptSourceBudgetChars, miniMaxJudgeMessages, parsedContinuePromptForJudge, parseJudgeJson, queueRecoveryRetryPrompt, retryPrompt } from "./judge";
|
||||
import { codeQueueEnvironmentHintTitle, injectCodeQueueEnvironmentHint, promptWithCodeQueueEnvironmentHint, userPromptForDisplay } from "./prompts";
|
||||
import { buildTaskTranscript, safePreview, transcriptLineSummaryLines } from "./task-view";
|
||||
@@ -201,12 +202,19 @@ function runQueueOrderingSelfTest(): JsonValue {
|
||||
const queuedBehindRunning = queueOrderTestTask("codex_4101_queued", "queued", "2026-05-11T10:01:00.000Z", "2026-05-11T10:01:00.000Z");
|
||||
const terminalAhead = queueOrderTestTask("codex_4200_done", "succeeded", "2026-05-11T11:00:00.000Z", "2026-05-11T11:00:00.000Z");
|
||||
const queuedAfterTerminal = queueOrderTestTask("codex_4201_queued", "queued", "2026-05-11T11:01:00.000Z", "2026-05-11T11:01:00.000Z");
|
||||
const mergeTargetEarly = queueOrderTestTask("codex_4300_target_early", "queued", "2026-05-11T12:00:00.000Z", "2026-05-11T12:00:00.000Z");
|
||||
const mergeSourceMiddle = queueOrderTestTask("codex_4301_source_middle", "queued", "2026-05-11T12:01:00.000Z", "2026-05-11T12:01:00.000Z");
|
||||
const mergeTargetLate = queueOrderTestTask("codex_4302_target_late", "queued", "2026-05-11T12:02:00.000Z", "2026-05-11T12:02:00.000Z");
|
||||
mergeTargetEarly.queueId = "queue_merge_target";
|
||||
mergeSourceMiddle.queueId = "queue_merge_target";
|
||||
mergeTargetLate.queueId = "queue_merge_target";
|
||||
const originalMaxActiveQueues = ctx().config.maxActiveQueues;
|
||||
|
||||
assertReferenceTest(ctx().queueHeadTask("queue_order_test", blockedByRetry)?.id === activeRetry.id, "retry_wait head must keep blocking a moved older-created task");
|
||||
assertReferenceTest(ctx().nextRunnableTaskFrom("queue_order_test", blockedByRetry)?.id === activeRetry.id, "next runnable should be the retry_wait head");
|
||||
assertReferenceTest(ctx().nextRunnableTaskFrom("queue_order_test", [queuedBehindRunning, runningHead]) === null, "running head must block queued tasks behind it");
|
||||
assertReferenceTest(ctx().nextRunnableTaskFrom("queue_order_test", [queuedAfterTerminal, terminalAhead])?.id === queuedAfterTerminal.id, "terminal head should not block later queued task");
|
||||
assertReferenceTest(ctx().queueHeadTask("queue_merge_target", [mergeTargetLate, mergeSourceMiddle, mergeTargetEarly])?.id === mergeTargetEarly.id, "merged queue should keep the earliest queueEnteredAt task first");
|
||||
assertReferenceTest(ctx().queuedStatusReason(queuedBehindRunning, [runningHead, queuedBehindRunning])?.label === "PREV TASK", "queued task behind an active same-queue task should expose PREV TASK reason");
|
||||
assertReferenceTest(ctx().availableQueueStartSlotsFor(8, 0) === Number.POSITIVE_INFINITY, "maxActiveQueues=0 should leave queue-to-queue concurrency unbounded");
|
||||
assertReferenceTest(ctx().availableQueueStartSlotsFor(0, 1) === 1, "empty active run slots should leave one slot available");
|
||||
@@ -245,6 +253,7 @@ function runQueueOrderingSelfTest(): JsonValue {
|
||||
{ name: "retry_wait_head_blocks_moved_older_created_task", ok: true, head: activeRetry.id, moved: movedOlderCreated.id },
|
||||
{ name: "running_head_blocks_later_queued_task", ok: true },
|
||||
{ name: "terminal_task_does_not_block_queue", ok: true },
|
||||
{ name: "queue_merge_preserves_chronological_task_order", ok: true },
|
||||
{ name: "queued_reason_prev_task", ok: true },
|
||||
{ name: "max_active_queues_zero_is_unbounded", ok: true },
|
||||
{ name: "idle_processing_queue_does_not_consume_active_run_slot", ok: true },
|
||||
@@ -335,6 +344,16 @@ function runTracePortSelfTest(): JsonValue {
|
||||
assertReferenceTest(String(edited.bodyPreview || "").includes("+const after = true;"), "opencode edit should use metadata diff for line diff display");
|
||||
assertReferenceTest(!transcript.some((line) => line.status === "opencode/step-start" || line.status === "opencode/step-finish"), "opencode step boundaries should stay out of trace");
|
||||
assertReferenceTest(!transcript.some((line) => String(line.bodyPreview || "").includes("<think>hidden reasoning</think>")), "reasoning-only opencode assistant text should not duplicate reasoning");
|
||||
const remoteTask = testTask("codex_5001_remote_opencode", "remote command prompt", "", [], "2026-05-12T00:01:00.000Z");
|
||||
remoteTask.providerId = "D601";
|
||||
remoteTask.cwd = "/home/ubuntu";
|
||||
remoteTask.model = minimaxM27Model;
|
||||
const remoteCommand = remoteOpenCodeRunCommandForTest(remoteTask, "hello 'world'");
|
||||
assertReferenceTest(remoteCommand.includes("exec opencode"), "remote opencode command must exec the opencode binary, not the run subcommand");
|
||||
assertReferenceTest(!remoteCommand.includes("exec 'run'") && !remoteCommand.includes("exec run --format"), "remote opencode command must not regress to exec run");
|
||||
assertReferenceTest(!openCodeTransportClosedBeforeTerminal(true, true, false), "exit=0 plus current final response must be terminal even without step_finish");
|
||||
assertReferenceTest(openCodeTransportClosedBeforeTerminal(true, false, false), "exit=0 without final response and step_finish is not a completed turn");
|
||||
assertReferenceTest(openCodeTransportClosedBeforeTerminal(false, true, true), "non-zero exit remains a transport failure even with partial text");
|
||||
return {
|
||||
ok: true,
|
||||
cases: [
|
||||
@@ -345,6 +364,10 @@ function runTracePortSelfTest(): JsonValue {
|
||||
{ name: "step_boundaries_filtered", ok: true },
|
||||
{ name: "reasoning_duplicate_filtered", ok: true },
|
||||
{ name: "duration_preserved", ok: true, durationMs: explored?.durationMs ?? null },
|
||||
{ name: "remote_opencode_exec_includes_binary", ok: true },
|
||||
{ name: "opencode_exit0_final_without_step_finish_is_terminal", ok: true },
|
||||
{ name: "opencode_exit0_without_final_or_step_finish_is_transport_closed", ok: true },
|
||||
{ name: "opencode_nonzero_exit_is_transport_closed", ok: true },
|
||||
],
|
||||
transcript: transcript.filter((line) => line.seq >= 2).map((line) => ({
|
||||
seq: line.seq,
|
||||
|
||||
@@ -11,10 +11,12 @@ export interface TaskOutputContext {
|
||||
logger: (level: "debug" | "info" | "warn" | "error", message: string, data?: JsonValue) => void;
|
||||
markTaskDirty: (taskId: string) => void;
|
||||
nowIso: () => string;
|
||||
onOutputAppended?: (task: QueueTask, output: LiveOutput, op: ArchivedLiveOutput["op"]) => void;
|
||||
schedulePersistState: () => void;
|
||||
}
|
||||
|
||||
const outputArchiveSeededTasks = new Set<string>();
|
||||
const archivedOutputCache = new Map<string, { signature: string; output: LiveOutput[] }>();
|
||||
let context: TaskOutputContext | null = null;
|
||||
|
||||
export function configureTaskOutput(runtimeContext: TaskOutputContext): void {
|
||||
@@ -56,6 +58,7 @@ function ensureTaskOutputArchiveSeeded(task: QueueTask): void {
|
||||
|
||||
function appendOutputArchive(task: QueueTask, output: LiveOutput, op: ArchivedLiveOutput["op"], text: string): void {
|
||||
try {
|
||||
archivedOutputCache.delete(task.id);
|
||||
mkdirSync(ctx().config.outputArchiveDir, { recursive: true });
|
||||
appendFileSync(taskOutputArchivePath(task.id), serializeArchivedOutput(output, op, text), "utf8");
|
||||
} catch (error) {
|
||||
@@ -84,6 +87,9 @@ function archiveRecordToOutput(value: unknown): ArchivedLiveOutput | null {
|
||||
function archivedTaskOutput(task: QueueTask): LiveOutput[] {
|
||||
const path = taskOutputArchivePath(task.id);
|
||||
if (!existsSync(path)) return [];
|
||||
const signature = outputArchiveSignature(task);
|
||||
const cached = archivedOutputCache.get(task.id);
|
||||
if (cached?.signature === signature) return cached.output;
|
||||
const bySeq = new Map<number, LiveOutput>();
|
||||
try {
|
||||
const text = readFileSync(path, "utf8");
|
||||
@@ -107,7 +113,13 @@ function archivedTaskOutput(task: QueueTask): LiveOutput[] {
|
||||
ctx().logger("warn", "codex_output_archive_read_failed", { taskId: task.id, error: ctx().errorToJson(error) });
|
||||
return [];
|
||||
}
|
||||
return Array.from(bySeq.values()).sort((left, right) => Number(left.seq) - Number(right.seq));
|
||||
const output = Array.from(bySeq.values()).sort((left, right) => Number(left.seq) - Number(right.seq));
|
||||
archivedOutputCache.set(task.id, { signature, output });
|
||||
while (archivedOutputCache.size > 80) {
|
||||
const firstKey = archivedOutputCache.keys().next().value;
|
||||
if (typeof firstKey === "string") archivedOutputCache.delete(firstKey);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function taskFullOutput(task: QueueTask): LiveOutput[] {
|
||||
@@ -151,6 +163,7 @@ function appendOutput(task: QueueTask, channel: OutputChannel, text: string, met
|
||||
task.updatedAt = ctx().nowIso();
|
||||
ctx().markTaskDirty(task.id);
|
||||
ctx().schedulePersistState();
|
||||
ctx().onOutputAppended?.(task, output, archiveOp);
|
||||
return output;
|
||||
}
|
||||
|
||||
|
||||
@@ -1206,15 +1206,53 @@ function fullTranscript(task: QueueTask): TranscriptLine[] {
|
||||
return cachedFullTranscript(task);
|
||||
}
|
||||
|
||||
function nonNegativeInteger(value: unknown): number | null {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : null;
|
||||
}
|
||||
|
||||
function retainedOutputStartsTraceStep(output: LiveOutput): boolean {
|
||||
if (output.channel === "diff" || output.channel === "tool") return true;
|
||||
if (output.channel !== "command") return false;
|
||||
const method = String(output.method || "");
|
||||
return method === "item/started" || (method !== "item/commandExecution/outputDelta" && method !== "item/completed");
|
||||
}
|
||||
|
||||
function retainedTaskStepCount(task: QueueTask): number {
|
||||
return (Array.isArray(task.output) ? task.output : []).reduce((count, output) => count + (retainedOutputStartsTraceStep(output) ? 1 : 0), 0);
|
||||
}
|
||||
|
||||
function taskStoredStepCount(task: QueueTask): number | null {
|
||||
return nonNegativeInteger(task.stepCount) ?? nonNegativeInteger(task.llmStepCount);
|
||||
}
|
||||
|
||||
// Used by overview/list responses and SSE patches. It must stay archive-free;
|
||||
// trace/detail endpoints compute exact counts from the full transcript on demand.
|
||||
function taskListStepCount(task: QueueTask): number {
|
||||
return taskStoredStepCount(task) ?? retainedTaskStepCount(task);
|
||||
}
|
||||
|
||||
function taskOutputMaxSeq(task: QueueTask): number {
|
||||
const output = Array.isArray(task.output) ? task.output : [];
|
||||
const promptHistory = Array.isArray(task.promptHistory) ? task.promptHistory : [];
|
||||
const attempts = Array.isArray(task.attempts) ? task.attempts : [];
|
||||
const values = [
|
||||
nonNegativeInteger(task.outputMaxSeq),
|
||||
nonNegativeInteger(output.at(-1)?.seq),
|
||||
nonNegativeInteger(promptHistory.at(-1)?.seq),
|
||||
...attempts.map((attempt) => nonNegativeInteger(attempt.outputEndSeq)),
|
||||
].filter((value): value is number => value !== null);
|
||||
return values.length > 0 ? Math.max(...values) : 0;
|
||||
}
|
||||
|
||||
function taskLlmStepCount(task: QueueTask): number {
|
||||
return taskStepCountFromTranscript(task, cachedPreviewTranscript(task));
|
||||
return taskStoredStepCount(task) ?? taskStepCountFromTranscript(task, cachedPreviewTranscript(task));
|
||||
}
|
||||
|
||||
function taskForMetaResponse(task: QueueTask): JsonValue {
|
||||
const fullOutput = taskFullOutput(task);
|
||||
const lastOutputSeq = fullOutput.at(-1)?.seq ?? 0;
|
||||
const lastOutputSeq = taskOutputMaxSeq(task);
|
||||
const displayPrompt = task.basePrompt || userPromptForDisplay(task.prompt);
|
||||
const stepCount = taskLlmStepCount(task);
|
||||
const stepCount = taskListStepCount(task);
|
||||
return {
|
||||
id: task.id,
|
||||
queueId: ctx().queueIdOf(task),
|
||||
@@ -1260,7 +1298,7 @@ function taskForMetaResponse(task: QueueTask): JsonValue {
|
||||
cancelRequested: task.cancelRequested,
|
||||
terminalUnread: terminalTaskUnread(task),
|
||||
nextMode: task.nextMode,
|
||||
outputCount: fullOutput.length,
|
||||
outputCount: task.output.length,
|
||||
retainedOutputCount: task.output.length,
|
||||
eventCount: task.events.length,
|
||||
transcriptCount: null,
|
||||
@@ -1274,8 +1312,8 @@ function taskForMetaResponse(task: QueueTask): JsonValue {
|
||||
|
||||
function taskForCompactMetaResponse(task: QueueTask): JsonValue {
|
||||
const displayPrompt = task.basePrompt || userPromptForDisplay(task.prompt);
|
||||
const lastOutputSeq = task.output.at(-1)?.seq ?? 0;
|
||||
const stepCount = taskLlmStepCount(task);
|
||||
const lastOutputSeq = taskOutputMaxSeq(task);
|
||||
const stepCount = taskListStepCount(task);
|
||||
return {
|
||||
id: task.id,
|
||||
queueId: ctx().queueIdOf(task),
|
||||
@@ -2093,7 +2131,9 @@ export {
|
||||
setAttemptInputPrompt,
|
||||
statsDaysFromUrl,
|
||||
taskExecutionSummary,
|
||||
taskListStepCount,
|
||||
taskLlmStepCount,
|
||||
taskOutputMaxSeq,
|
||||
taskForCompactMetaResponse,
|
||||
taskForMetaResponse,
|
||||
taskForResponse,
|
||||
|
||||
@@ -261,6 +261,9 @@ export interface QueueTask {
|
||||
codexThreadId: string | null;
|
||||
activeTurnId: string | null;
|
||||
finalResponse: string;
|
||||
stepCount?: number;
|
||||
llmStepCount?: number;
|
||||
outputMaxSeq?: number;
|
||||
lastError: string | null;
|
||||
lastJudge: JudgeResult | null;
|
||||
judgeFailCount: number;
|
||||
|
||||
Reference in New Issue
Block a user