diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index fce440fbc..b227c659f 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -100,7 +100,10 @@ import { } from "@/components/canvas/canvas-skill-icons"; import { HomeAnimationEmbed } from "@/components/home/home-animation-embed"; import { + DEFAULT_OPENCLAW_TASK_TIMEOUT_MS, DEFAULT_OPENCLAW_CHAT_BASE_URL, + MAX_OPENCLAW_TASK_TIMEOUT_MS, + MIN_OPENCLAW_TASK_TIMEOUT_MS, OPENCLAW_CHAT_CONFIG_KEY, type OpenClawChatConfig, } from "@/lib/openclaw-chat/config"; @@ -2232,7 +2235,7 @@ export function AdminSettingsClient() { enabled: false, baseUrl: DEFAULT_OPENCLAW_CHAT_BASE_URL, token: "", - timeoutMs: 180_000, + timeoutMs: DEFAULT_OPENCLAW_TASK_TIMEOUT_MS, }); const [openClawChatTokenConfigured, setOpenClawChatTokenConfigured] = useState(false); @@ -2525,8 +2528,11 @@ export function AdminSettingsClient() { String(raw.baseUrl || "").trim() || DEFAULT_OPENCLAW_CHAT_BASE_URL, token: "", timeoutMs: Math.min( - 300_000, - Math.max(5_000, Math.round(Number(raw.timeoutMs) || 180_000)) + MAX_OPENCLAW_TASK_TIMEOUT_MS, + Math.max( + MIN_OPENCLAW_TASK_TIMEOUT_MS, + Math.round(Number(raw.timeoutMs) || DEFAULT_OPENCLAW_TASK_TIMEOUT_MS) + ) ), }); setOpenClawChatTokenConfigured( @@ -4444,8 +4450,14 @@ export function AdminSettingsClient() { openClawChatConfig.baseUrl.trim() || DEFAULT_OPENCLAW_CHAT_BASE_URL, token: openClawChatConfig.token.trim(), timeoutMs: Math.min( - 300_000, - Math.max(5_000, Math.round(Number(openClawChatConfig.timeoutMs) || 180_000)) + MAX_OPENCLAW_TASK_TIMEOUT_MS, + Math.max( + MIN_OPENCLAW_TASK_TIMEOUT_MS, + Math.round( + Number(openClawChatConfig.timeoutMs) || + DEFAULT_OPENCLAW_TASK_TIMEOUT_MS + ) + ) ), }; @@ -12831,21 +12843,27 @@ export function AdminSettingsClient() {
setOpenClawChatConfig((prev) => ({ ...prev, - timeoutMs: Number(event.target.value) || 180000, + timeoutMs: + Number(event.target.value) || + DEFAULT_OPENCLAW_TASK_TIMEOUT_MS, })) } + step={60_000} type="number" value={openClawChatConfig.timeoutMs} /> +

+ 网络断开会自动续接,该时间只限制业务任务的后台等待周期。 +

diff --git a/app/api/canvas/clawd-chat/cancel/route.ts b/app/api/canvas/clawd-chat/cancel/route.ts index 970b4ed52..72818b5de 100644 --- a/app/api/canvas/clawd-chat/cancel/route.ts +++ b/app/api/canvas/clawd-chat/cancel/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { auth } from "@/app/(auth)/auth"; -import { getChatById } from "@/lib/db/queries"; +import { getChatById, getSystemSetting } from "@/lib/db/queries"; import { buildClawdCancelKey, cancelClawdRequest, @@ -10,7 +10,18 @@ import { OPENCLAW_AUTH_REQUIRED_CODE, OPENCLAW_AUTH_REQUIRED_MESSAGE, } from "@/lib/openclaw-chat/errors"; -import { deletePersistedClawdTask } from "@/lib/openclaw-chat/task-store"; +import { + normalizeOpenClawChatConfig, + OPENCLAW_CHAT_CONFIG_KEY, +} from "@/lib/openclaw-chat/config"; +import { + deletePersistedClawdTask, + getPersistedClawdTask, +} from "@/lib/openclaw-chat/task-store"; +import { + getPersistedUpstreamTaskState, + stopOpenClawDurableTask, +} from "@/lib/openclaw-chat/upstream-task-client"; const cancelSchema = z.object({ id: z.string().uuid(), @@ -39,13 +50,34 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } - const active = cancelClawdRequest( - buildClawdCancelKey({ - userId: session.user.id, - chatId: parsed.data.id, - userMessageId: parsed.data.userMessageId, - }) - ); + const cancelKey = buildClawdCancelKey({ + userId: session.user.id, + chatId: parsed.data.id, + userMessageId: parsed.data.userMessageId, + }); + const task = await getPersistedClawdTask({ + chatId: parsed.data.id, + userMessageId: parsed.data.userMessageId, + }).catch(() => null); + const active = cancelClawdRequest(cancelKey); + const upstreamTask = task + ? getPersistedUpstreamTaskState(task.metadata) + : null; + if (upstreamTask) { + const setting = await getSystemSetting({ + key: OPENCLAW_CHAT_CONFIG_KEY, + }).catch(() => null); + const config = normalizeOpenClawChatConfig(setting?.value); + if (config.enabled && config.baseUrl && config.token) { + await stopOpenClawDurableTask({ config, upstreamTask }).catch((error) => { + console.warn("[ClawdChat Cancel] Upstream stop deferred:", { + chatId: parsed.data.id, + taskId: upstreamTask.taskId, + error, + }); + }); + } + } const deleted = await deletePersistedClawdTask({ chatId: parsed.data.id, diff --git a/app/api/canvas/clawd-chat/route.ts b/app/api/canvas/clawd-chat/route.ts index 5e6904e9c..b20345b12 100644 --- a/app/api/canvas/clawd-chat/route.ts +++ b/app/api/canvas/clawd-chat/route.ts @@ -37,11 +37,17 @@ import { import { appendPersistedClawdTaskProgress, getPersistedClawdTask, + markClawdTaskCanceled, markClawdTaskCompleted, markClawdTaskFailed, markClawdTaskRunning, persistPendingClawdTask, } from "@/lib/openclaw-chat/task-store"; +import { + OpenClawTaskDetachedError, + submitOpenClawDurableTask, + waitForOpenClawDurableTask, +} from "@/lib/openclaw-chat/upstream-task-client"; import { generateUUID } from "@/lib/utils"; const DEFAULT_PROCESSING_HINT = @@ -92,21 +98,6 @@ type ClawdSpreadsheetAttachment = { truncated: boolean; }; -type OpenClawBackgroundTask = { - jobId: string; - state: string; - stage: string; - phase: string; - displayText: string; - updatedAt: string; - completed: number; - total: number | null; - progressPercent: number | null; - pollEndpoint: string; - pollAfterMs: number; - maxWaitSeconds: number; -}; - type ExecuteClawdTaskParams = { assistantMessageId: string; cancelKey: string; @@ -237,121 +228,6 @@ async function readOpenClawConfig() { return normalizeOpenClawChatConfig(setting?.value); } -function parseSseBlock(block: string) { - let event = "message"; - const dataLines: string[] = []; - for (const line of block.split(/\r?\n/)) { - if (line.startsWith("event:")) event = line.slice(6).trim() || "message"; - if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart()); - } - if (dataLines.length === 0) return null; - try { - return { event, data: JSON.parse(dataLines.join("\n")) as unknown }; - } catch { - return null; - } -} - -function parseOpenClawBackgroundTask( - payload: unknown -): OpenClawBackgroundTask | null { - if (!payload || typeof payload !== "object" || Array.isArray(payload)) { - return null; - } - const rawPayload = payload as Record; - const rawTask = - rawPayload.background_task && - typeof rawPayload.background_task === "object" && - !Array.isArray(rawPayload.background_task) - ? (rawPayload.background_task as Record) - : null; - if (!rawTask) return null; - - const jobId = String(rawTask.job_id || "").trim(); - const state = String(rawTask.state || "").trim(); - if (!jobId || !state) return null; - const completed = Math.max(0, Number(rawTask.completed) || 0); - const parsedTotal = Number(rawTask.total); - const total = - Number.isFinite(parsedTotal) && parsedTotal > 0 ? parsedTotal : null; - const parsedPercent = Number(rawTask.progress_percent); - - return { - jobId, - state, - stage: String(rawTask.stage || "").trim(), - phase: String(rawTask.phase || "").trim(), - displayText: String( - rawTask.display_text || rawTask.phase || rawPayload.answer || "" - ).trim(), - updatedAt: String(rawTask.updated_at || "").trim(), - completed, - total, - progressPercent: - rawTask.progress_percent != null && Number.isFinite(parsedPercent) - ? Math.max(0, Math.min(100, parsedPercent)) - : total - ? Math.round((completed * 100) / total) - : null, - pollEndpoint: String(rawTask.poll_endpoint || "").trim(), - pollAfterMs: Math.max( - 800, - Math.min(10_000, Number(rawTask.poll_after_ms) || 1500) - ), - maxWaitSeconds: Math.max( - 1, - Math.min(25, Number(rawTask.max_wait_seconds) || 20) - ), - }; -} - -function buildOpenClawTaskProgress(task: OpenClawBackgroundTask) { - const message = - task.displayText || - task.phase || - (task.state === "processing" ? "任务正在处理" : "任务状态已更新"); - return { - step_code: task.stage || "background_task", - step_state: - task.state === "failed" - ? "failed" - : task.state === "processing" - ? "started" - : "completed", - message, - display: message, - completed: task.completed, - ...(task.total != null ? { total: task.total } : {}), - ...(task.progressPercent != null - ? { progress_percent: task.progressPercent } - : {}), - stage: task.stage, - state: task.state, - updated_at: task.updatedAt, - }; -} - -function buildOpenClawTaskStatusUrl(params: { - baseUrl: string; - task: OpenClawBackgroundTask; - userId: string; - conversationId: string; -}) { - const base = new URL(params.baseUrl); - const endpoint = params.task.pollEndpoint.startsWith("/") - ? params.task.pollEndpoint - : "/api/chat/task-status"; - const url = new URL(endpoint, base.origin); - url.searchParams.set("job_id", params.task.jobId); - url.searchParams.set("user_id", params.userId); - url.searchParams.set("conversation_id", params.conversationId); - url.searchParams.set("wait_seconds", String(params.task.maxWaitSeconds)); - if (params.task.updatedAt) { - url.searchParams.set("known_updated_at", params.task.updatedAt); - } - return url; -} - function getAnswer(payload: unknown) { if (!payload || typeof payload !== "object" || Array.isArray(payload)) { return ""; @@ -367,145 +243,8 @@ function getRequestId(payload: unknown) { return value ? String(value) : null; } -async function readOpenClawSse(params: { - response: Response; - chatId: string; - assistantMessageId: string; -}) { - const reader = params.response.body?.getReader(); - if (!reader) throw new Error("OpenClaw stream is unavailable"); - const decoder = new TextDecoder(); - let buffer = ""; - let finalPayload: Record | null = null; - - const processBlock = async (block: string) => { - const event = parseSseBlock(block); - if (!event) return; - if (event.event === "progress") { - if (event.data && typeof event.data === "object") { - await appendPersistedClawdTaskProgress({ - chatId: params.chatId, - assistantMessageId: params.assistantMessageId, - progress: event.data as Record, - }); - } - return; - } - if ( - event.event === "final" && - event.data && - typeof event.data === "object" && - !Array.isArray(event.data) - ) { - finalPayload = event.data as Record; - } - if (event.event === "error") { - throw new Error("OpenClaw stream returned an error"); - } - }; - - while (true) { - const { done, value } = await reader.read(); - buffer += decoder.decode(value || new Uint8Array(), { stream: !done }); - const blocks = buffer.split(/\r?\n\r?\n/); - buffer = blocks.pop() || ""; - for (const block of blocks) { - await processBlock(block); - } - if (done) break; - } - if (buffer.trim()) await processBlock(buffer); - - if (!finalPayload) throw new Error("OpenClaw stream ended without a result"); - return finalPayload; -} - -async function pollOpenClawBackgroundTask(params: { - config: ExecuteClawdTaskParams["config"]; - initialPayload: Record; - initialTask: OpenClawBackgroundTask; - controller: AbortController; - chatId: string; - assistantMessageId: string; - userId: string; - conversationId: string; -}) { - let task = params.initialTask; - let finalPayload = params.initialPayload; - let failureCount = 0; - - await appendPersistedClawdTaskProgress({ - chatId: params.chatId, - assistantMessageId: params.assistantMessageId, - progress: buildOpenClawTaskProgress(task), - }); - - while (task.state === "processing") { - if (!task.pollEndpoint) { - throw new Error("OpenClaw background task has no status endpoint"); - } - try { - const response = await fetch( - buildOpenClawTaskStatusUrl({ - baseUrl: params.config.baseUrl, - task, - userId: params.userId, - conversationId: params.conversationId, - }), - { - headers: { - Authorization: `Bearer ${params.config.token}`, - Accept: "application/json", - }, - cache: "no-store", - signal: params.controller.signal, - } - ); - if (!response.ok) { - throw new Error(`OpenClaw task status failed: ${response.status}`); - } - const payload = (await response.json().catch(() => null)) as - | Record - | null; - const nextTask = parseOpenClawBackgroundTask(payload); - if (!payload || !nextTask) { - throw new Error("OpenClaw task status payload is invalid"); - } - failureCount = 0; - finalPayload = { - ...payload, - request_id: - payload.request_id || params.initialPayload.request_id || null, - }; - task = nextTask; - await appendPersistedClawdTaskProgress({ - chatId: params.chatId, - assistantMessageId: params.assistantMessageId, - progress: buildOpenClawTaskProgress(task), - }); - } catch (error) { - if (params.controller.signal.aborted) throw error; - failureCount += 1; - if (failureCount >= 5) throw error; - await appendPersistedClawdTaskProgress({ - chatId: params.chatId, - assistantMessageId: params.assistantMessageId, - progress: { - step_code: task.stage || "background_task", - step_state: "waiting", - message: "任务仍在运行,正在重新连接进度服务", - display: "任务仍在运行,正在重新连接进度服务", - }, - }); - await new Promise((resolve) => setTimeout(resolve, task.pollAfterMs)); - } - } - return finalPayload; -} - async function executeClawdTask(params: ExecuteClawdTaskParams) { const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), params.config.timeoutMs); registerActiveClawdRequest( params.cancelKey, controller, @@ -519,8 +258,7 @@ async function executeClawdTask(params: ExecuteClawdTaskParams) { assistantMessageId: params.assistantMessageId, }); - const isSensitiveSubmission = Boolean(params.sensitiveInput); - const upstreamBody = JSON.stringify({ + const upstreamBody: Record = { conversation_id: params.conversationId, user_id: params.userId, user_name: params.userName, @@ -560,64 +298,106 @@ async function executeClawdTask(params: ExecuteClawdTaskParams) { params.spreadsheetMarkdownTruncated || undefined, ...params.metadata, }, - stream_progress: !isSensitiveSubmission, + stream_progress: false, sensitive_input: params.sensitiveInput, - }); - const response = await fetch(`${params.config.baseUrl}/api/chat`, { - method: "POST", - headers: { - Authorization: `Bearer ${params.config.token}`, - "Content-Type": "application/json", - Accept: isSensitiveSubmission ? "application/json" : "text/event-stream", - }, - body: upstreamBody, - signal: controller.signal, - cache: "no-store", - }); + }; - if (!response.ok) { - throw new Error(`OpenClaw request failed: ${response.status}`); - } - - const contentType = response.headers.get("content-type") || ""; - let payload = contentType.includes("text/event-stream") - ? await readOpenClawSse({ - response, - chatId: params.chatId, - assistantMessageId: params.assistantMessageId, - }) - : ((await response.json().catch(() => null)) as Record< + if (params.sensitiveInput) { + const timeout = setTimeout( + () => + controller.abort( + new DOMException("OpenClaw request timed out", "TimeoutError") + ), + params.config.timeoutMs + ); + try { + const response = await fetch(`${params.config.baseUrl}/api/chat`, { + method: "POST", + headers: { + Authorization: `Bearer ${params.config.token}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(upstreamBody), + signal: controller.signal, + cache: "no-store", + }); + const payload = (await response.json().catch(() => null)) as Record< string, unknown - > | null); - if (!payload) throw new Error("OpenClaw returned an invalid response"); - - const backgroundTask = parseOpenClawBackgroundTask(payload); - if (backgroundTask?.state === "processing") { - payload = await pollOpenClawBackgroundTask({ - config: params.config, - initialPayload: payload, - initialTask: backgroundTask, - controller, - chatId: params.chatId, - assistantMessageId: params.assistantMessageId, - userId: params.userId, - conversationId: params.conversationId, - }); + > | null; + if (!response.ok) { + throw new Error(`OpenClaw request failed: ${response.status}`); + } + if (!payload) throw new Error("OpenClaw returned an invalid response"); + const answer = getAnswer(payload); + if (!answer) throw new Error("OpenClaw returned an empty answer"); + await markClawdTaskCompleted({ + chatId: params.chatId, + assistantMessageId: params.assistantMessageId, + answer, + requestId: getRequestId(payload), + }); + clearClawdRequestCancellation(params.cancelKey); + return; + } finally { + clearTimeout(timeout); + } } - if (isClawdRequestCanceled(params.cancelKey)) return; - const answer = getAnswer(payload); - if (!answer) throw new Error("OpenClaw returned an empty answer"); - await markClawdTaskCompleted({ + const target = { chatId: params.chatId, assistantMessageId: params.assistantMessageId, - answer, - requestId: getRequestId(payload), + }; + const upstreamTask = await submitOpenClawDurableTask({ + config: params.config, + body: upstreamBody, + idempotencyKey: `${params.userId}:${params.chatId}:${params.userMessageId}`, + target, + signal: controller.signal, }); + const outcome = await waitForOpenClawDurableTask({ + config: params.config, + upstreamTask, + target, + deadlineAt: Date.now() + params.config.timeoutMs, + signal: controller.signal, + }); + + if (isClawdRequestCanceled(params.cancelKey)) return; + if (outcome.status === "completed") { + await markClawdTaskCompleted({ + ...target, + answer: outcome.answer, + requestId: outcome.requestId, + }); + } else if (outcome.status === "canceled") { + await markClawdTaskCanceled({ + ...target, + message: outcome.error, + }); + } else if (outcome.status === "failed") { + await markClawdTaskFailed({ + ...target, + error: outcome.error, + }); + } clearClawdRequestCancellation(params.cancelKey); } catch (error) { if (isClawdRequestCanceled(params.cancelKey)) return; + if (error instanceof OpenClawTaskDetachedError) { + await appendPersistedClawdTaskProgress({ + chatId: params.chatId, + assistantMessageId: params.assistantMessageId, + progress: { + step_code: "task.detached", + step_state: "waiting", + message: "任务仍在 OpenClaw 后台执行,打开原对话后会自动续接结果", + display: "任务仍在 OpenClaw 后台执行,打开原对话后会自动续接结果", + }, + }).catch(() => null); + return; + } console.error("[ClawdChat API] Durable task failed:", error); await markClawdTaskFailed({ chatId: params.chatId, @@ -632,7 +412,6 @@ async function executeClawdTask(params: ExecuteClawdTaskParams) { params.sensitiveInput.verification_code = ""; } params.sensitiveInput = undefined; - clearTimeout(timeout); unregisterActiveClawdRequest(params.cancelKey); } } diff --git a/app/api/canvas/clawd-chat/status/route.ts b/app/api/canvas/clawd-chat/status/route.ts index 81be3d3f1..70c369393 100644 --- a/app/api/canvas/clawd-chat/status/route.ts +++ b/app/api/canvas/clawd-chat/status/route.ts @@ -1,12 +1,32 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { auth } from "@/app/(auth)/auth"; -import { getChatById } from "@/lib/db/queries"; +import { getChatById, getSystemSetting } from "@/lib/db/queries"; +import { + buildClawdCancelKey, + hasActiveClawdRequest, + registerActiveClawdRequest, + unregisterActiveClawdRequest, +} from "@/lib/openclaw-chat/cancel-registry"; +import { + normalizeOpenClawChatConfig, + OPENCLAW_CHAT_CONFIG_KEY, +} from "@/lib/openclaw-chat/config"; import { OPENCLAW_AUTH_REQUIRED_CODE, OPENCLAW_AUTH_REQUIRED_MESSAGE, } from "@/lib/openclaw-chat/errors"; -import { getPersistedClawdTask } from "@/lib/openclaw-chat/task-store"; +import { + getPersistedClawdTask, + markClawdTaskCanceled, + markClawdTaskCompleted, + markClawdTaskFailed, +} from "@/lib/openclaw-chat/task-store"; +import { isPendingClawdTaskStatus } from "@/lib/openclaw-chat/task-state"; +import { + getPersistedUpstreamTaskState, + refreshOpenClawDurableTask, +} from "@/lib/openclaw-chat/upstream-task-client"; const statusSchema = z.object({ id: z.string().uuid(), @@ -15,6 +35,64 @@ const statusSchema = z.object({ export const dynamic = "force-dynamic"; export const revalidate = 0; +export const maxDuration = 60; + +async function reconcileDetachedOpenClawTask(params: { + chatId: string; + userId: string; + task: NonNullable>>; +}) { + if (!isPendingClawdTaskStatus(params.task.metadata.status)) return; + const upstreamTask = getPersistedUpstreamTaskState(params.task.metadata); + if (!upstreamTask) return; + const cancelKey = buildClawdCancelKey({ + userId: params.userId, + chatId: params.chatId, + userMessageId: params.task.metadata.userMessageId, + }); + if (hasActiveClawdRequest(cancelKey)) return; + + const controller = new AbortController(); + registerActiveClawdRequest(cancelKey, controller, 30_000); + const target = { + chatId: params.chatId, + assistantMessageId: params.task.metadata.assistantMessageId, + }; + try { + const setting = await getSystemSetting({ + key: OPENCLAW_CHAT_CONFIG_KEY, + }).catch(() => null); + const config = normalizeOpenClawChatConfig(setting?.value); + if (!config.enabled || !config.baseUrl || !config.token) return; + const outcome = await refreshOpenClawDurableTask({ + config, + upstreamTask, + target, + signal: controller.signal, + }); + if (outcome.status === "completed") { + await markClawdTaskCompleted({ + ...target, + answer: outcome.answer, + requestId: outcome.requestId, + }); + } else if (outcome.status === "failed") { + await markClawdTaskFailed({ ...target, error: outcome.error }); + } else if (outcome.status === "canceled") { + await markClawdTaskCanceled({ ...target, message: outcome.error }); + } + } catch (error) { + if (!controller.signal.aborted) { + console.warn("[ClawdChat Status] Upstream reconciliation deferred:", { + chatId: params.chatId, + taskId: upstreamTask.taskId, + error, + }); + } + } finally { + unregisterActiveClawdRequest(cancelKey); + } +} export async function GET(request: Request) { const session = await auth().catch(() => null); @@ -45,7 +123,7 @@ export async function GET(request: Request) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } - const task = await getPersistedClawdTask({ + let task = await getPersistedClawdTask({ chatId: parsed.data.id, userMessageId: parsed.data.userMessageId, }); @@ -56,6 +134,17 @@ export async function GET(request: Request) { ); } + await reconcileDetachedOpenClawTask({ + chatId: parsed.data.id, + userId: session.user.id, + task, + }); + task = + (await getPersistedClawdTask({ + chatId: parsed.data.id, + userMessageId: parsed.data.userMessageId, + })) || task; + const userTextPart = Array.isArray(task.userMessage?.parts) ? task.userMessage.parts.find( (part) => diff --git a/app/api/messages/route.ts b/app/api/messages/route.ts index 8983da8a1..65b4f9114 100644 --- a/app/api/messages/route.ts +++ b/app/api/messages/route.ts @@ -32,6 +32,15 @@ const FAL_IMAGE_TASK_POLL_INTERVAL_MS = 2_000; const falTaskLastPolledAt = new Map(); const falTaskSyncInFlight = new Set(); +function toPublicVideoMetadata(value: DBMessage["videoMetadata"]) { + if (!value || typeof value !== "object" || Array.isArray(value)) return value; + const metadata = value as Record; + if (metadata.taskKind !== "clawd_chat") return value; + const publicMetadata = { ...metadata }; + delete publicMetadata.upstreamTaskKey; + return publicMetadata; +} + function createEmptyMessagesResponse() { return NextResponse.json( { messages: [] }, @@ -277,7 +286,7 @@ export async function GET(request: NextRequest) { createdAt: msg.createdAt, parts: parts, attachments: msg.attachments || [], - videoMetadata: msg.videoMetadata || null, + videoMetadata: toPublicVideoMetadata(msg.videoMetadata) || null, }; }); diff --git a/lib/openclaw-chat/cancel-registry.ts b/lib/openclaw-chat/cancel-registry.ts index 8896d37be..dfd21cd88 100644 --- a/lib/openclaw-chat/cancel-registry.ts +++ b/lib/openclaw-chat/cancel-registry.ts @@ -5,8 +5,16 @@ type ActiveClawdRequest = { expiresAt: number; }; -const activeRequests = new Map(); -const canceledRequests = new Map(); +const registry = globalThis as typeof globalThis & { + __activeClawdRequests?: Map; + __canceledClawdRequests?: Map; +}; +const activeRequests = + registry.__activeClawdRequests || new Map(); +const canceledRequests = + registry.__canceledClawdRequests || new Map(); +registry.__activeClawdRequests = activeRequests; +registry.__canceledClawdRequests = canceledRequests; function cleanupExpiredClawdRequests(now = Date.now()) { for (const [key, request] of activeRequests.entries()) { @@ -50,6 +58,11 @@ export function unregisterActiveClawdRequest(key: string) { activeRequests.delete(key); } +export function hasActiveClawdRequest(key: string) { + cleanupExpiredClawdRequests(); + return activeRequests.has(key); +} + export function cancelClawdRequest(key: string) { cleanupExpiredClawdRequests(); canceledRequests.set(key, Date.now() + CANCELED_REQUEST_TTL_MS); diff --git a/lib/openclaw-chat/config.ts b/lib/openclaw-chat/config.ts index 6fa3c3124..d1ae5ddb9 100644 --- a/lib/openclaw-chat/config.ts +++ b/lib/openclaw-chat/config.ts @@ -20,11 +20,12 @@ export type AdminOpenClawChatConfig = Omit & { tokenPreview: string; }; -const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000; -const MIN_TIMEOUT_MS = 5_000; +export const DEFAULT_OPENCLAW_TASK_TIMEOUT_MS = 30 * 60 * 1000; +export const MIN_OPENCLAW_TASK_TIMEOUT_MS = 5 * 60 * 1000; // Complex OpenClaw skills can legitimately run for up to thirty minutes. // Keep this aligned with the route maxDuration and the integration contract. -const MAX_TIMEOUT_MS = 30 * 60 * 1000; +export const MAX_OPENCLAW_TASK_TIMEOUT_MS = 30 * 60 * 1000; +const LEGACY_CONNECTION_TIMEOUT_MAX_MS = 5 * 60 * 1000; function readRecord(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) @@ -41,8 +42,16 @@ function normalizeBaseUrl(value: unknown) { function normalizeTimeoutMs(value: unknown) { const numeric = Number(value); - if (!Number.isFinite(numeric)) return DEFAULT_TIMEOUT_MS; - return Math.min(MAX_TIMEOUT_MS, Math.max(MIN_TIMEOUT_MS, Math.round(numeric))); + if (!Number.isFinite(numeric)) return DEFAULT_OPENCLAW_TASK_TIMEOUT_MS; + // Older settings described one SSE connection and were commonly saved as + // 180-300 seconds. Durable tasks use a separate business-task lifetime. + if (numeric <= LEGACY_CONNECTION_TIMEOUT_MAX_MS) { + return DEFAULT_OPENCLAW_TASK_TIMEOUT_MS; + } + return Math.min( + MAX_OPENCLAW_TASK_TIMEOUT_MS, + Math.max(MIN_OPENCLAW_TASK_TIMEOUT_MS, Math.round(numeric)) + ); } export function normalizeOpenClawChatConfig( diff --git a/lib/openclaw-chat/task-secret.ts b/lib/openclaw-chat/task-secret.ts new file mode 100644 index 000000000..1e37e12c6 --- /dev/null +++ b/lib/openclaw-chat/task-secret.ts @@ -0,0 +1,67 @@ +import "server-only"; + +import { + createCipheriv, + createDecipheriv, + createHash, + randomBytes, +} from "node:crypto"; + +const ENCRYPTED_SECRET_PREFIX = "enc:v1"; + +function getEncryptionKey() { + const secret = String( + process.env.OPENCLAW_TASK_SECRET || + process.env.AUTH_SECRET || + process.env.NEXTAUTH_SECRET || + "" + ).trim(); + if (!secret) { + throw new Error( + "OPENCLAW_TASK_SECRET or AUTH_SECRET is required for durable tasks" + ); + } + return createHash("sha256").update(secret, "utf8").digest(); +} + +export function encryptOpenClawTaskSecret(value: string) { + const normalized = value.trim(); + if (!normalized || normalized.startsWith(`${ENCRYPTED_SECRET_PREFIX}:`)) { + return normalized; + } + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", getEncryptionKey(), iv); + const encrypted = Buffer.concat([ + cipher.update(normalized, "utf8"), + cipher.final(), + ]); + const authTag = cipher.getAuthTag(); + return [ + ENCRYPTED_SECRET_PREFIX, + iv.toString("base64url"), + authTag.toString("base64url"), + encrypted.toString("base64url"), + ].join(":"); +} + +export function decryptOpenClawTaskSecret(value: string) { + const normalized = value.trim(); + if (!normalized.startsWith(`${ENCRYPTED_SECRET_PREFIX}:`)) { + // Backward compatibility for tasks persisted before encryption was added. + return normalized; + } + const [, , ivValue, authTagValue, encryptedValue] = normalized.split(":"); + if (!ivValue || !authTagValue || !encryptedValue) { + throw new Error("Invalid encrypted OpenClaw task secret"); + } + const decipher = createDecipheriv( + "aes-256-gcm", + getEncryptionKey(), + Buffer.from(ivValue, "base64url") + ); + decipher.setAuthTag(Buffer.from(authTagValue, "base64url")); + return Buffer.concat([ + decipher.update(Buffer.from(encryptedValue, "base64url")), + decipher.final(), + ]).toString("utf8"); +} diff --git a/lib/openclaw-chat/task-state.ts b/lib/openclaw-chat/task-state.ts index 20e382f33..e23292594 100644 --- a/lib/openclaw-chat/task-state.ts +++ b/lib/openclaw-chat/task-state.ts @@ -22,6 +22,10 @@ export type ClawdTaskMetadata = { skillTagName?: string | null; requestId?: string | null; error?: string | null; + upstreamTaskId?: string | null; + upstreamTaskKey?: string | null; + upstreamEventsEndpoint?: string | null; + upstreamLastEventId?: number | null; createdAt: number; updatedAt: number; }; @@ -56,6 +60,7 @@ export function normalizeClawdTaskMetadata( const createdAt = Number(raw.createdAt); const updatedAt = Number(raw.updatedAt); + const upstreamLastEventId = Number(raw.upstreamLastEventId); return { composerMode: "clawd", source: "openclaw", @@ -74,6 +79,14 @@ export function normalizeClawdTaskMetadata( skillTagName: raw.skillTagName ? String(raw.skillTagName) : null, requestId: raw.requestId ? String(raw.requestId) : null, error: raw.error ? String(raw.error) : null, + upstreamTaskId: raw.upstreamTaskId ? String(raw.upstreamTaskId) : null, + upstreamTaskKey: raw.upstreamTaskKey ? String(raw.upstreamTaskKey) : null, + upstreamEventsEndpoint: raw.upstreamEventsEndpoint + ? String(raw.upstreamEventsEndpoint) + : null, + upstreamLastEventId: Number.isFinite(upstreamLastEventId) + ? Math.max(0, Math.trunc(upstreamLastEventId)) + : null, createdAt: Number.isFinite(createdAt) ? createdAt : Date.now(), updatedAt: Number.isFinite(updatedAt) ? updatedAt : Date.now(), }; diff --git a/lib/openclaw-chat/task-store.ts b/lib/openclaw-chat/task-store.ts index 651498505..f24c9468a 100644 --- a/lib/openclaw-chat/task-store.ts +++ b/lib/openclaw-chat/task-store.ts @@ -8,6 +8,7 @@ import { updateMessageById, } from "@/lib/db/queries"; import type { DBMessage } from "@/lib/db/schema"; +import { encryptOpenClawTaskSecret } from "@/lib/openclaw-chat/task-secret"; import { appendClawdTaskProgress, CLAWD_TASK_KIND, @@ -29,6 +30,13 @@ type PendingClawdTaskParams = { processingHint: string; }; +export type ClawdUpstreamTaskState = { + taskId: string; + taskKey?: string | null; + eventsEndpoint?: string | null; + lastEventId?: number | null; +}; + function buildTaskMetadata(params: { userMessageId: string; assistantMessageId: string; @@ -39,6 +47,7 @@ function buildTaskMetadata(params: { progressEvents?: ClawdTaskProgressPayload[]; requestId?: string | null; error?: string | null; + upstreamTask?: ClawdUpstreamTaskState | null; createdAt?: number; updatedAt?: number; }): ClawdTaskMetadata { @@ -56,6 +65,12 @@ function buildTaskMetadata(params: { skillTagName: params.skillTagName || null, requestId: params.requestId || null, error: params.error || null, + upstreamTaskId: params.upstreamTask?.taskId || null, + upstreamTaskKey: params.upstreamTask?.taskKey + ? encryptOpenClawTaskSecret(params.upstreamTask.taskKey) + : null, + upstreamEventsEndpoint: params.upstreamTask?.eventsEndpoint || null, + upstreamLastEventId: params.upstreamTask?.lastEventId ?? null, createdAt: params.createdAt || now, updatedAt: params.updatedAt || now, }; @@ -134,6 +149,7 @@ async function updateClawdAssistantTask(params: { parts?: DBMessage["parts"]; requestId?: string | null; error?: string | null; + upstreamTask?: ClawdUpstreamTaskState; }) { const rows = await getMessageById({ id: params.assistantMessageId }); const message = rows.find((row) => row.chatId === params.chatId); @@ -151,6 +167,22 @@ async function updateClawdAssistantTask(params: { requestId: params.requestId !== undefined ? params.requestId : metadata.requestId, error: params.error !== undefined ? params.error : metadata.error, + upstreamTaskId: + params.upstreamTask?.taskId !== undefined + ? params.upstreamTask.taskId + : metadata.upstreamTaskId, + upstreamTaskKey: + params.upstreamTask?.taskKey !== undefined + ? encryptOpenClawTaskSecret(params.upstreamTask.taskKey || "") || null + : metadata.upstreamTaskKey, + upstreamEventsEndpoint: + params.upstreamTask?.eventsEndpoint !== undefined + ? params.upstreamTask.eventsEndpoint + : metadata.upstreamEventsEndpoint, + upstreamLastEventId: + params.upstreamTask?.lastEventId !== undefined + ? params.upstreamTask.lastEventId + : metadata.upstreamLastEventId, updatedAt: Date.now(), }; await updateMessageById({ @@ -173,11 +205,26 @@ export async function appendPersistedClawdTaskProgress(params: { chatId: string; assistantMessageId: string; progress: ClawdTaskProgressPayload; + upstreamTask?: ClawdUpstreamTaskState; }) { return updateClawdAssistantTask({ ...params, status: "running", progress: params.progress, + upstreamTask: params.upstreamTask, + }); +} + +export async function persistClawdUpstreamTaskState(params: { + chatId: string; + assistantMessageId: string; + upstreamTask: ClawdUpstreamTaskState; +}) { + return updateClawdAssistantTask({ + chatId: params.chatId, + assistantMessageId: params.assistantMessageId, + status: "running", + upstreamTask: params.upstreamTask, }); } @@ -210,6 +257,21 @@ export async function markClawdTaskFailed(params: { }); } +export async function markClawdTaskCanceled(params: { + chatId: string; + assistantMessageId: string; + message?: string; +}) { + const message = params.message || "任务已停止"; + return updateClawdAssistantTask({ + chatId: params.chatId, + assistantMessageId: params.assistantMessageId, + status: "canceled", + parts: [{ type: "text", text: message }], + error: null, + }); +} + export async function getPersistedClawdTask(params: { chatId: string; userMessageId?: string | null; diff --git a/lib/openclaw-chat/upstream-task-client.ts b/lib/openclaw-chat/upstream-task-client.ts new file mode 100644 index 000000000..1f132596b --- /dev/null +++ b/lib/openclaw-chat/upstream-task-client.ts @@ -0,0 +1,514 @@ +import "server-only"; + +import { + appendPersistedClawdTaskProgress, + type ClawdUpstreamTaskState, + persistClawdUpstreamTaskState, +} from "@/lib/openclaw-chat/task-store"; +import type { ClawdTaskMetadata } from "@/lib/openclaw-chat/task-state"; +import { decryptOpenClawTaskSecret } from "@/lib/openclaw-chat/task-secret"; + +type OpenClawUpstreamConfig = { + baseUrl: string; + token: string; + timeoutMs: number; +}; + +type ClawdTaskPersistenceTarget = { + chatId: string; + assistantMessageId: string; +}; + +export type OpenClawTaskOutcome = + | { + status: "pending"; + upstreamTask: ClawdUpstreamTaskState; + } + | { + status: "completed"; + upstreamTask: ClawdUpstreamTaskState; + answer: string; + requestId: string | null; + } + | { + status: "failed" | "canceled"; + upstreamTask: ClawdUpstreamTaskState; + error: string; + }; + +const UPSTREAM_REQUEST_TIMEOUT_MS = 15_000; +const UPSTREAM_POLL_INTERVAL_MS = 1600; +const TERMINAL_STATUSES = new Set([ + "succeeded", + "completed", + "failed", + "cancelled", + "canceled", +]); + +class OpenClawUpstreamHttpError extends Error { + constructor( + message: string, + readonly status: number + ) { + super(message); + this.name = "OpenClawUpstreamHttpError"; + } +} + +function readRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function readText(value: unknown) { + return typeof value === "string" ? value.trim() : ""; +} + +function readEventId(value: unknown) { + const parsed = Number(value); + return Number.isFinite(parsed) ? Math.max(0, Math.trunc(parsed)) : 0; +} + +function getTaskStatus(payload: unknown) { + return readText(readRecord(readRecord(payload).task).status).toLowerCase(); +} + +function getTaskEventsEndpoint(payload: unknown, taskId: string) { + const task = readRecord(readRecord(payload).task); + return ( + readText(task.events_endpoint) || + readText(readRecord(payload).events_endpoint) || + `/api/chat/tasks/${encodeURIComponent(taskId)}/events` + ); +} + +function getAnswer(payload: unknown) { + const root = readRecord(payload); + const output = readRecord(root.output); + const result = readRecord(root.result); + return ( + readText(root.answer) || readText(output.answer) || readText(result.answer) + ); +} + +function getRequestId(payload: unknown) { + const root = readRecord(payload); + const output = readRecord(root.output); + return readText(root.request_id) || readText(output.request_id) || null; +} + +function getTaskError(payload: unknown, fallback: string) { + const root = readRecord(payload); + const error = readRecord(root.error); + const outputError = readRecord(readRecord(root.output).error); + return ( + readText(error.message) || + readText(outputError.message) || + readText(root.message) || + fallback + ); +} + +function buildUpstreamUrl(baseUrl: string, endpoint: string) { + const normalizedBaseUrl = `${baseUrl.replace(/\/+$/, "")}/`; + return new URL(endpoint, normalizedBaseUrl); +} + +function buildTaskEndpoint(taskId: string) { + return `/api/chat/tasks/${encodeURIComponent(taskId)}`; +} + +function buildTaskControlEndpoint(taskId: string) { + return `${buildTaskEndpoint(taskId)}/control`; +} + +function createRequestController(parentSignal?: AbortSignal) { + const controller = new AbortController(); + const abortFromParent = () => controller.abort(parentSignal?.reason); + if (parentSignal?.aborted) { + abortFromParent(); + } else { + parentSignal?.addEventListener("abort", abortFromParent, { once: true }); + } + const timeout = setTimeout( + () => controller.abort(new DOMException("OpenClaw request timed out", "TimeoutError")), + UPSTREAM_REQUEST_TIMEOUT_MS + ); + return { + signal: controller.signal, + cleanup() { + clearTimeout(timeout); + parentSignal?.removeEventListener("abort", abortFromParent); + }, + }; +} + +async function fetchJson(params: { + config: OpenClawUpstreamConfig; + endpoint: string; + method?: "GET" | "POST"; + body?: Record; + taskKey?: string | null; + idempotencyKey?: string; + signal?: AbortSignal; +}) { + const requestController = createRequestController(params.signal); + try { + const response = await fetch( + buildUpstreamUrl(params.config.baseUrl, params.endpoint), + { + method: params.method || "GET", + headers: { + Authorization: `Bearer ${params.config.token}`, + Accept: "application/json", + ...(params.body ? { "Content-Type": "application/json" } : {}), + ...(params.taskKey + ? { "X-Dalongxia-Task-Key": params.taskKey } + : {}), + ...(params.idempotencyKey + ? { "Idempotency-Key": params.idempotencyKey } + : {}), + }, + body: params.body ? JSON.stringify(params.body) : undefined, + cache: "no-store", + signal: requestController.signal, + } + ); + const payload = (await response.json().catch(() => null)) as unknown; + if (!response.ok) { + throw new OpenClawUpstreamHttpError( + getTaskError( + payload, + `OpenClaw request failed: ${response.status}` + ), + response.status + ); + } + return payload; + } finally { + requestController.cleanup(); + } +} + +function mergeUpstreamTaskState( + current: ClawdUpstreamTaskState, + payload: unknown +) { + const root = readRecord(payload); + const task = readRecord(root.task); + const taskId = + readText(root.task_id) || readText(task.task_id) || current.taskId; + const eventId = Math.max( + current.lastEventId || 0, + readEventId(root.next_after_event_id), + readEventId(root.event_id) + ); + return { + taskId, + taskKey: readText(root.task_key) || current.taskKey || null, + eventsEndpoint: + getTaskEventsEndpoint(payload, taskId) || current.eventsEndpoint || null, + lastEventId: eventId, + } satisfies ClawdUpstreamTaskState; +} + +function normalizeProgressEvent( + event: unknown, + taskId: string +): Record | null { + const raw = readRecord(event); + if (Object.keys(raw).length === 0) return null; + const data = readRecord(raw.data); + const eventId = readEventId(raw.event_id); + const phase = readText(raw.phase); + const eventType = readText(raw.event_type); + const message = + readText(data.message) || + readText(data.display) || + readText(data.display_text) || + readText(raw.display_text) || + phase || + "任务状态已更新"; + return { + ...data, + event_id: eventId, + task_id: taskId, + event_type: eventType, + status: readText(raw.status), + phase, + step_code: readText(data.step_code) || phase || eventType || "task.progress", + step_state: readText(data.step_state) || readText(raw.status) || "started", + message, + display: message, + created_at: raw.created_at, + }; +} + +export function getPersistedUpstreamTaskState( + metadata: ClawdTaskMetadata +): ClawdUpstreamTaskState | null { + const taskId = readText(metadata.upstreamTaskId); + if (!taskId) return null; + return { + taskId, + taskKey: metadata.upstreamTaskKey + ? decryptOpenClawTaskSecret(metadata.upstreamTaskKey) + : null, + eventsEndpoint: + readText(metadata.upstreamEventsEndpoint) || + `/api/chat/tasks/${encodeURIComponent(taskId)}/events`, + lastEventId: readEventId(metadata.upstreamLastEventId), + }; +} + +export async function submitOpenClawDurableTask(params: { + config: OpenClawUpstreamConfig; + body: Record; + idempotencyKey: string; + target: ClawdTaskPersistenceTarget; + signal?: AbortSignal; +}) { + let payload: unknown; + for (let attempt = 1; attempt <= 3; attempt += 1) { + try { + payload = await fetchJson({ + config: params.config, + endpoint: "/api/chat/tasks", + method: "POST", + body: params.body, + idempotencyKey: params.idempotencyKey, + signal: params.signal, + }); + break; + } catch (error) { + if (params.signal?.aborted) throw error; + if ( + error instanceof OpenClawUpstreamHttpError && + error.status >= 400 && + error.status < 500 && + error.status !== 408 && + error.status !== 429 + ) { + throw error; + } + if (attempt >= 3) throw error; + await waitForDelay(attempt * 500, params.signal); + } + } + const root = readRecord(payload); + const task = readRecord(root.task); + const taskId = readText(root.task_id) || readText(task.task_id); + if (!taskId) throw new Error("OpenClaw 未返回 task_id"); + const submittedTask = mergeUpstreamTaskState( + { + taskId, + taskKey: readText(root.task_key) || null, + eventsEndpoint: getTaskEventsEndpoint(payload, taskId), + lastEventId: 0, + }, + payload + ); + // A task-creation response can include the latest upstream cursor even though + // none of its events have been persisted locally yet. Always consume the + // event stream from the beginning after accepting a new durable task. + const upstreamTask = { ...submittedTask, lastEventId: 0 }; + await persistClawdUpstreamTaskState({ + ...params.target, + upstreamTask, + }); + return upstreamTask; +} + +export async function stopOpenClawDurableTask(params: { + config: OpenClawUpstreamConfig; + upstreamTask: ClawdUpstreamTaskState; + signal?: AbortSignal; +}) { + return fetchJson({ + config: params.config, + endpoint: buildTaskControlEndpoint(params.upstreamTask.taskId), + method: "POST", + body: { action: "stop" }, + taskKey: params.upstreamTask.taskKey, + signal: params.signal, + }); +} + +export async function refreshOpenClawDurableTask(params: { + config: OpenClawUpstreamConfig; + upstreamTask: ClawdUpstreamTaskState; + target: ClawdTaskPersistenceTarget; + signal?: AbortSignal; +}): Promise { + const eventsUrl = buildUpstreamUrl( + params.config.baseUrl, + params.upstreamTask.eventsEndpoint || + `/api/chat/tasks/${encodeURIComponent(params.upstreamTask.taskId)}/events` + ); + eventsUrl.searchParams.set( + "after_event_id", + String(params.upstreamTask.lastEventId || 0) + ); + const eventsPayload = await fetchJson({ + config: params.config, + endpoint: eventsUrl.toString(), + taskKey: params.upstreamTask.taskKey, + signal: params.signal, + }); + const mergedUpstreamTask = mergeUpstreamTaskState( + params.upstreamTask, + eventsPayload + ); + let upstreamTask = { + ...mergedUpstreamTask, + lastEventId: params.upstreamTask.lastEventId || 0, + }; + const events = Array.isArray(readRecord(eventsPayload).events) + ? (readRecord(eventsPayload).events as unknown[]) + : []; + for (const event of events) { + const eventId = readEventId(readRecord(event).event_id); + upstreamTask = { + ...upstreamTask, + lastEventId: Math.max(upstreamTask.lastEventId || 0, eventId), + }; + const progress = normalizeProgressEvent(event, upstreamTask.taskId); + if (!progress) continue; + await appendPersistedClawdTaskProgress({ + ...params.target, + progress, + upstreamTask, + }); + } + upstreamTask = { + ...upstreamTask, + lastEventId: Math.max( + upstreamTask.lastEventId || 0, + readEventId(readRecord(eventsPayload).next_after_event_id) + ), + }; + await persistClawdUpstreamTaskState({ + ...params.target, + upstreamTask, + }); + + const status = getTaskStatus(eventsPayload); + if (!TERMINAL_STATUSES.has(status)) { + return { status: "pending", upstreamTask }; + } + + const terminalPayload = await fetchJson({ + config: params.config, + endpoint: buildTaskEndpoint(upstreamTask.taskId), + taskKey: upstreamTask.taskKey, + signal: params.signal, + }); + upstreamTask = mergeUpstreamTaskState(upstreamTask, terminalPayload); + const terminalStatus = getTaskStatus(terminalPayload) || status; + if (terminalStatus === "succeeded" || terminalStatus === "completed") { + const answer = getAnswer(terminalPayload); + if (!answer) { + return { + status: "failed", + upstreamTask, + error: "OpenClaw 任务已结束,但未返回有效结果", + }; + } + return { + status: "completed", + upstreamTask, + answer, + requestId: getRequestId(terminalPayload), + }; + } + if (terminalStatus === "cancelled" || terminalStatus === "canceled") { + return { status: "canceled", upstreamTask, error: "任务已停止" }; + } + return { + status: "failed", + upstreamTask, + error: getTaskError(terminalPayload, "OpenClaw 任务执行失败"), + }; +} + +function waitForDelay(delayMs: number, signal?: AbortSignal) { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(signal.reason || new DOMException("Aborted", "AbortError")); + return; + } + let timer: ReturnType; + const onAbort = () => { + clearTimeout(timer); + reject(signal?.reason || new DOMException("Aborted", "AbortError")); + }; + timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, delayMs); + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +export class OpenClawTaskDetachedError extends Error { + constructor() { + super("OpenClaw task continues in the background"); + this.name = "OpenClawTaskDetachedError"; + } +} + +export async function waitForOpenClawDurableTask(params: { + config: OpenClawUpstreamConfig; + upstreamTask: ClawdUpstreamTaskState; + target: ClawdTaskPersistenceTarget; + deadlineAt: number; + signal?: AbortSignal; +}) { + let upstreamTask = params.upstreamTask; + let failureCount = 0; + while (!params.signal?.aborted && Date.now() < params.deadlineAt) { + try { + const outcome = await refreshOpenClawDurableTask({ + config: params.config, + upstreamTask, + target: params.target, + signal: params.signal, + }); + upstreamTask = outcome.upstreamTask; + failureCount = 0; + if (outcome.status !== "pending") return outcome; + } catch (error) { + if (params.signal?.aborted) throw error; + if ( + error instanceof OpenClawUpstreamHttpError && + error.status >= 400 && + error.status < 500 && + error.status !== 408 && + error.status !== 429 + ) { + throw error; + } + failureCount += 1; + await appendPersistedClawdTaskProgress({ + ...params.target, + upstreamTask, + progress: { + step_code: "task.reconnecting", + step_state: "waiting", + message: "任务仍在 OpenClaw 后台运行,正在重新连接进度服务", + display: "任务仍在 OpenClaw 后台运行,正在重新连接进度服务", + }, + }); + } + await waitForDelay( + Math.min(5000, UPSTREAM_POLL_INTERVAL_MS + failureCount * 500), + params.signal + ); + } + if (params.signal?.aborted) { + throw params.signal.reason || new DOMException("Aborted", "AbortError"); + } + throw new OpenClawTaskDetachedError(); +} diff --git a/tests/routes/openclaw-config.test.ts b/tests/routes/openclaw-config.test.ts index 39e3466e5..f5b611783 100644 --- a/tests/routes/openclaw-config.test.ts +++ b/tests/routes/openclaw-config.test.ts @@ -24,4 +24,12 @@ test.describe("OpenClaw chat configuration", () => { }).timeoutMs ).toBe(30 * 60 * 1000); }); + + test("migrates legacy three-minute connection timeout to task lifetime", () => { + expect( + normalizeOpenClawChatConfig({ + timeoutMs: 180_000, + }).timeoutMs + ).toBe(30 * 60 * 1000); + }); }); diff --git a/tests/routes/openclaw-durable-task-state.test.ts b/tests/routes/openclaw-durable-task-state.test.ts index 2478c8671..53fe356d4 100644 --- a/tests/routes/openclaw-durable-task-state.test.ts +++ b/tests/routes/openclaw-durable-task-state.test.ts @@ -16,6 +16,10 @@ test.describe("OpenClaw durable task state", () => { assistantMessageId: "assistant-message-id", processingHint: "正在处理", progressEvents: [{ event_id: 1, message: "请求已接收" }], + upstreamTaskId: "task_test_123", + upstreamTaskKey: "enc:v1:iv:tag:ciphertext", + upstreamEventsEndpoint: "/api/chat/tasks/task_test_123/events", + upstreamLastEventId: 18, createdAt: 100, updatedAt: 200, }); @@ -26,6 +30,8 @@ test.describe("OpenClaw durable task state", () => { expect(task?.progressEvents).toEqual([ { event_id: 1, message: "请求已接收" }, ]); + expect(task?.upstreamTaskId).toBe("task_test_123"); + expect(task?.upstreamLastEventId).toBe(18); }); test("deduplicates replayed progress events while keeping new steps", () => {