feat(mcp): resume queued calculation jobs

This commit is contained in:
2026-10-29 18:25:22 +08:00
parent 52681a8451
commit 799b99f310
20 changed files with 348 additions and 42 deletions
+3
View File
@@ -1,3 +1,4 @@
import type { TaskCalculationWaiter } from "../mcp/task-calculation-waiter.js";
import { registerInteractionRequestRoutes } from "./interaction-request-routes.js";
import type { InteractionRequestStore } from "../sessions/interaction-request-store.js";
import { Router, type NextFunction, type Request, type Response } from "express";
@@ -33,6 +34,7 @@ import { registerArtifactRoutes } from "./artifact-routes.js";
import type { AgentMessageCoordinator } from "../chat/agent-message-coordinator.js";
export type BuildChatRouterOptions = {
waitTaskCalculations?: TaskCalculationWaiter;
interactionRequestStore?: InteractionRequestStore;
agentMessages?: AgentMessageCoordinator;
frontendActionCoordinator: FrontendActionCoordinator;
@@ -170,6 +172,7 @@ export const buildChatRouter = (options: BuildChatRouterOptions) => {
registerResponseInputRoutes(chatRouter, { runtime, sessionMetadataStore, runState });
registerChatStreamRoute(chatRouter, {
waitTaskCalculations: options.waitTaskCalculations,
interactionRequestStore: options.interactionRequestStore,
agentMessages,
frontendActionCoordinator,
+6 -4
View File
@@ -77,7 +77,7 @@ type StreamPromptOptions = {
onInboxEvent?: (type: string, data: { inboxID?: string; delivery?: "steer" | "queue" }) => void;
waitForUserInput?: () => Promise<boolean>;
beforePrompt?: () => Promise<void>;
beforeIdle?: () => Promise<boolean>;
beforeIdle?: () => Promise<boolean | string>;
beforeRichAnswerClose?: () => Promise<boolean>;
runtime: OpencodeRuntimeAdapter;
sessionId: string;
@@ -904,7 +904,7 @@ export const streamPromptResponse = async ({
id: part.id,
phase: "tool",
status: normalizeToolStatus(part.state.status),
title: getToolProgressTitle(part.tool, part.state.status),
title: getToolProgressTitle(part.tool, part.state.status, part.state.status === "completed" ? part.state.output : undefined),
tool: { name: part.tool, call_id: part.callID, input: sanitizeToolProgressInput(toolParams) },
detail: buildToolProgressDetail(
part.tool,
@@ -912,6 +912,7 @@ export const streamPromptResponse = async ({
toolParams,
reason,
part.state.status === "error" ? part.state.error : undefined,
part.state.status === "completed" ? part.state.output : undefined,
),
});
if (
@@ -1022,12 +1023,13 @@ export const streamPromptResponse = async ({
});
continue;
}
if (beforeIdle && !await beforeIdle()) {
const idleDecision = beforeIdle ? await beforeIdle() : true;
if (idleDecision === false || typeof idleDecision === "string") {
// A durable message won the race with completion. Continue within
// the same managed session/lease, never create another task run.
finalAnswer = undefined;
sawResponseActivity = false;
await runtime.prompt(sessionId, "请处理收信箱中新到达的调查协作消息,按需回复,然后完成本轮工作。", toRuntimeModel(model));
await runtime.prompt(sessionId, typeof idleDecision === "string" ? idleDecision : "请处理收信箱中新到达的调查协作消息,按需回复,然后完成本轮工作。", toRuntimeModel(model));
continue;
}
if (waitForUserInput && await waitForUserInput()) {
+24 -1
View File
@@ -468,6 +468,7 @@ export const buildToolProgressDetail = (
params: Record<string, unknown>,
reason: string,
error?: string,
output?: string,
) => {
const toolName = toolLabels[tool] ?? tool;
const reasonText = reason ? `;说明:${containsForbiddenPersistentContent(reason) ? "[redacted]" : reason}` : "";
@@ -478,6 +479,8 @@ export const buildToolProgressDetail = (
return `${toolName} 调用失败${reasonText}${paramsText}${errorText}`;
}
if (status === "completed") {
const calculation = calculationProgress(output);
if (calculation) return `${toolName}:${calculation.label};计算任务:${calculation.jobId}${reasonText}${paramsText}`;
return `${toolName} 已执行完成${reasonText}${paramsText}`;
}
if (status === "pending") {
@@ -486,8 +489,28 @@ export const buildToolProgressDetail = (
return `${toolName} 正在执行${reasonText}${paramsText}`;
};
export const getToolProgressTitle = (tool: string, status: string) => {
const calculationProgress = (output?: string) => {
if (!output || output.length > 100_000) return null;
try {
const value = JSON.parse(output);
if (value?.semantics !== "task" || typeof value.data?.job_id !== "string" ||
!/^[0-9a-f-]{36}$/i.test(value.data.job_id)) return null;
const labels: Record<string, string> = {
queued: "已提交,等待计算", running: "后台计算中", cancel_requested: "已请求取消,等待计算停止",
succeeded: "计算已完成", failed: "计算失败", cancelled: "计算已取消",
interrupted: "计算已中断", timed_out: "计算执行超时",
};
const label = labels[value.data.status];
return label ? { label, jobId: value.data.job_id } : null;
} catch { return null; }
};
export const getToolProgressTitle = (tool: string, status: string, output?: string) => {
const toolName = toolLabels[tool] ?? tool;
if (status === "completed") {
const calculation = calculationProgress(output);
if (calculation) return `${toolName}:${calculation.label}`;
}
if (status === "completed") return `${toolName} 已完成`;
if (status === "error") return `${toolName} 执行失败`;
if (status === "pending") return `准备调用 ${toolName}`;
+13 -6
View File
@@ -1,3 +1,4 @@
import type { TaskCalculationWaiter } from "../mcp/task-calculation-waiter.js";
import { createResponsesProjection, responseRequestSchema, responsePrompt } from "../chat/responses-protocol.js";
import { ResponseInputTracker, WaitingResponseInputQueue } from "../chat/response-inputs.js";
import { reconcileInteractionDelivery } from "../sessions/interaction-delivery-reconciliation.js";
@@ -252,6 +253,7 @@ export const buildFailedTaskExecutionPlan = (history: Record<string, unknown>[])
};
type RegisterChatStreamRouteOptions = {
waitTaskCalculations?: TaskCalculationWaiter;
interactionRequestStore?: InteractionRequestStore;
agentMessages?: AgentMessageCoordinator;
frontendActionCoordinator: FrontendActionCoordinator;
@@ -269,6 +271,7 @@ export const registerChatStreamRoute = (
chatRouter: Router,
{
interactionRequestStore,
waitTaskCalculations,
frontendActionCoordinator,
learningOrchestrator,
memoryStore,
@@ -1005,9 +1008,11 @@ export const registerChatStreamRoute = (
if (messaging && (messaging.hasInputThisTurn(binding.sessionId) || !await messaging.closeInteractive(activeSessionRecord))) return false;
return inputTracker.canClose();
},
beforeIdle: messaging ? async () => {
if (sessionPurpose !== "task_execution") return messaging.closeInteractive(activeSessionRecord);
if (await messaging.flush(binding.sessionId)) return false;
beforeIdle: messaging || (sessionPurpose === "task_execution" && waitTaskCalculations) ? async () => {
if (sessionPurpose !== "task_execution") return messaging ? messaging.closeInteractive(activeSessionRecord) : true;
if (await messaging?.flush(binding.sessionId)) return false;
const calculationResume = await waitTaskCalculations?.(binding.sessionId, abortController.signal);
if (calculationResume) return calculationResume;
await taskArtifactPublishQueue;
if (taskLeaseFailure || !activeSessionRecord.taskId || !activeSessionRecord.runId) return true;
taskCompleting = true;
@@ -1018,13 +1023,13 @@ export const registerChatStreamRoute = (
const task = await operationalTaskClient.completeTaskRun(binding.sessionId,
activeSessionRecord.taskId, activeSessionRecord.runId, clientSessionId);
taskRunFinalized = true;
messaging.stop(binding.sessionId);
messaging?.stop(binding.sessionId);
await stopTaskHeartbeat();
if (task.lifecycle_status === "failed") throw new Error("Server rejected incomplete investigation artifacts");
return true;
} catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "PENDING_AGENT_MESSAGES") {
await messaging.flush(binding.sessionId);
await messaging?.flush(binding.sessionId);
return false;
}
throw error;
@@ -1048,7 +1053,6 @@ export const registerChatStreamRoute = (
publish("execution_plan", buildFailedTaskExecutionPlan(executionPlanHistory));
}
await taskArtifactPublishQueue;
await stopTaskHeartbeat();
if (
sessionPurpose === "task_execution"
&& activeSessionRecord.taskId
@@ -1068,6 +1072,7 @@ export const registerChatStreamRoute = (
taskRunFinalized = true;
} else {
try {
if (await waitTaskCalculations?.(binding.sessionId, abortController.signal)) throw Object.assign(new Error("Calculation results require investigation continuation"), { code: "CALCULATION_CONTINUATION_REQUIRED" });
const completedTask = await operationalTaskClient.completeTaskRun(
binding.sessionId,
activeSessionRecord.taskId,
@@ -1096,6 +1101,7 @@ export const registerChatStreamRoute = (
}
}
}
await stopTaskHeartbeat();
await persistQueue.catch((error) => {
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
});
@@ -1153,6 +1159,7 @@ export const registerChatStreamRoute = (
session_id: clientSessionId,
title: sessionTitle,
});
await stopTaskHeartbeat();
await persistQueue.catch((error) => {
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
});