diff --git a/.env.example b/.env.example index 3896f0f..9044674 100644 --- a/.env.example +++ b/.env.example @@ -504,6 +504,8 @@ VITE_TKMIND_WORKING_DIR=/Users/john/PycharmProjects/tkmind # IMAGE_MAKE_SEMANTIC_REVIEW_MAX_ATTEMPTS=3 # H5_WECHAT_MP_FRESH_PAGE_THUMBNAILS=1 # H5_WECHAT_MP_FRESH_THUMBNAIL_REPAIR=0 +# 微信服务号 Agent 回复超时(默认 20 分钟);page.generate 超时后可在 MEMIND_CURSOR_PAGE_TIMEOUT_TAKEOVER=1 时由 Cursor 接手 +# H5_WECHAT_MP_AGENT_REPLY_TIMEOUT_MS=1200000 # 微信 Page Data 独立 Aider 审核;默认关闭,灰度名单支持 userId/username/slug/displayName/nickname 或 *。 # 该链路不创建 h5_agent_runs,审核收据写入用户工作区 .memind/page-data-reviews/。 # H5_WECHAT_MP_PAGE_DATA_AIDER_REVIEW_ENABLED=0 diff --git a/agent-run-gateway.mjs b/agent-run-gateway.mjs index ba48ea3..345eb5c 100644 --- a/agent-run-gateway.mjs +++ b/agent-run-gateway.mjs @@ -3360,6 +3360,7 @@ export function createAgentRunGateway({ listRunEventsForUser, dispatchRun, getQueueStatus, + getToolGatewayStatus: () => (toolGateway?.getStatus ? toolGateway.getStatus() : null), dispatchQueuedRuns, recoverStaleRunningRuns, }; diff --git a/cursor-page-timeout-takeover.mjs b/cursor-page-timeout-takeover.mjs index b0c6446..0b8647d 100644 --- a/cursor-page-timeout-takeover.mjs +++ b/cursor-page-timeout-takeover.mjs @@ -64,6 +64,29 @@ function cursorExecutorAvailable(toolGatewayStatus, env = process.env) { return executors.includes('cursor'); } +const PAGE_TIMEOUT_TAKEOVER_ERROR_CODES = new Set([ + 'AGENT_RUN_TIMEOUT', + 'WECHAT_AGENT_REPLY_TIMEOUT', +]); + +export function isPageTimeoutTakeoverError(error) { + return PAGE_TIMEOUT_TAKEOVER_ERROR_CODES.has(String(error?.code ?? '')); +} + +export function buildPageTimeoutTakeoverRowFromDisplayText(displayText) { + const text = String(displayText ?? '').trim(); + return { + user_message_json: JSON.stringify({ + role: 'user', + content: [{ type: 'text', text }], + metadata: { + displayText: text, + memindRun: { toolMode: 'chat' }, + }, + }), + }; +} + export function shouldRetryPageGenerationWithCursor({ row, error = null, @@ -71,7 +94,7 @@ export function shouldRetryPageGenerationWithCursor({ env = process.env, } = {}) { if (!cursorPageTimeoutTakeoverEnabled(env)) return false; - if (String(error?.code ?? '') !== 'AGENT_RUN_TIMEOUT') return false; + if (!isPageTimeoutTakeoverError(error)) return false; if (!cursorExecutorAvailable(toolGatewayStatus, env)) return false; const userMessage = typeof row?.user_message_json === 'string' diff --git a/cursor-page-timeout-takeover.test.mjs b/cursor-page-timeout-takeover.test.mjs index 7976b62..503f970 100644 --- a/cursor-page-timeout-takeover.test.mjs +++ b/cursor-page-timeout-takeover.test.mjs @@ -82,6 +82,28 @@ test('buildCursorPageTimeoutTakeover rewrites message for cursor code executor', ); }); +test('shouldRetryPageGenerationWithCursor accepts wechat agent reply timeout', () => { + const row = { + user_message_json: JSON.stringify({ + role: 'user', + content: [{ type: 'text', text: '帮我生成 Google 今日热门页面' }], + metadata: { + displayText: '帮我生成 Google 今日热门页面', + memindRun: { toolMode: 'chat' }, + }, + }), + }; + assert.equal( + shouldRetryPageGenerationWithCursor({ + row, + error: { code: 'WECHAT_AGENT_REPLY_TIMEOUT' }, + toolGatewayStatus: { enabled: true, executors: ['cursor'] }, + env: enabledEnv, + }), + true, + ); +}); + test('resolveCursorPageTimeoutTakeover returns null for non-timeout errors', () => { const row = { user_message_json: JSON.stringify({ diff --git a/wechat-cursor-agent-run.mjs b/wechat-cursor-agent-run.mjs index 9592f5e..133e0dc 100644 --- a/wechat-cursor-agent-run.mjs +++ b/wechat-cursor-agent-run.mjs @@ -55,7 +55,7 @@ export async function executeCursorChannelCodeRun({ taskType = null, policy = null, forceCursorExecutor = false, - timeoutMs = 15 * 60 * 1000, + timeoutMs = 20 * 60 * 1000, pollMs = DEFAULT_POLL_MS, logger = console, } = {}) { @@ -118,26 +118,47 @@ export async function executeCursorChannelCodeRun({ taskType: cursorRuntime.taskType ?? resolvedTaskType, }); agentRunGateway.dispatchRun(run.id); + return pollCursorRunToReply({ + agentRunGateway, + userId, + runId: run.id, + requestId, + channel, + timeoutMs, + pollMs, + logger, + }); +} +async function pollCursorRunToReply({ + agentRunGateway, + userId, + runId, + requestId, + channel, + timeoutMs, + pollMs, + logger, +}) { const deadline = Date.now() + Math.max(0, Number(timeoutMs) || 0); while (Date.now() <= deadline) { - const latest = await agentRunGateway.getRunForUser(userId, run.id); + const latest = await agentRunGateway.getRunForUser(userId, runId); if (!latest) { throw Object.assign(new Error('Cursor 任务丢失'), { code: 'CURSOR_CHANNEL_RUN_MISSING', }); } if (latest.status === 'succeeded') { - const text = await readCursorCompletionText(agentRunGateway, userId, run.id); + const text = await readCursorCompletionText(agentRunGateway, userId, runId); logger.info?.('[cursor-channel] run succeeded', { userId, - runId: run.id, + runId, requestId, channel, }); return { text, - runId: run.id, + runId, tokenState: null, messages: [{ role: 'assistant', @@ -160,6 +181,49 @@ export async function executeCursorChannelCodeRun({ }); } +export async function executeCursorTakeoverRun({ + agentRunGateway, + userId, + sessionId = null, + requestId, + userMessage, + runOptions = {}, + channel = 'wechat_mp', + timeoutMs = 20 * 60 * 1000, + pollMs = DEFAULT_POLL_MS, + logger = console, +} = {}) { + if (!agentRunGateway?.createRun || !agentRunGateway?.dispatchRun || !agentRunGateway?.getRunForUser) { + throw Object.assign(new Error('Cursor 执行网关不可用'), { + code: 'CURSOR_CHANNEL_GATEWAY_UNAVAILABLE', + }); + } + const normalizedRequestId = String(requestId ?? '').trim(); + if (!normalizedRequestId) { + throw Object.assign(new Error('缺少 request_id'), { code: 'CURSOR_CHANNEL_RUN_MISSING' }); + } + + const run = await agentRunGateway.createRun(userId, { + sessionId, + requestId: normalizedRequestId, + userMessage, + toolMode: runOptions.toolMode ?? 'code', + taskType: runOptions.taskType ?? null, + forceDeepReasoning: runOptions.forceDeepReasoning ?? true, + }); + agentRunGateway.dispatchRun(run.id); + return pollCursorRunToReply({ + agentRunGateway, + userId, + runId: run.id, + requestId: normalizedRequestId, + channel, + timeoutMs, + pollMs, + logger, + }); +} + export async function executeWechatCursorAgentRun({ agentRunGateway, userId, @@ -169,7 +233,7 @@ export async function executeWechatCursorAgentRun({ agentPrompt, intentKind = 'page.generate', policy = null, - timeoutMs = 15 * 60 * 1000, + timeoutMs = 20 * 60 * 1000, pollMs = DEFAULT_POLL_MS, logger = console, } = {}) { diff --git a/wechat-cursor-page-timeout-takeover.mjs b/wechat-cursor-page-timeout-takeover.mjs new file mode 100644 index 0000000..10898d8 --- /dev/null +++ b/wechat-cursor-page-timeout-takeover.mjs @@ -0,0 +1,82 @@ +import crypto from 'node:crypto'; +import { + cursorExecutorEnabled, + resolvePreferredCodeExecutor, +} from './cursor-agent-launch.mjs'; +import { + buildPageTimeoutTakeoverRowFromDisplayText, + resolveCursorPageTimeoutTakeover, +} from './cursor-page-timeout-takeover.mjs'; +import { executeCursorTakeoverRun } from './wechat-cursor-agent-run.mjs'; + +function resolveToolGatewayStatus(agentRunGateway, env = process.env) { + const fromGateway = agentRunGateway?.getToolGatewayStatus?.() + ?? agentRunGateway?.getQueueStatus?.()?.toolGateway + ?? null; + if (fromGateway?.enabled) return fromGateway; + if (!cursorExecutorEnabled(env) || resolvePreferredCodeExecutor(env) !== 'cursor') { + return null; + } + return { enabled: true, executors: ['cursor'] }; +} + +export function buildWechatPageTimeoutTakeoverRow(intent) { + return buildPageTimeoutTakeoverRowFromDisplayText( + intent?.displayText ?? intent?.agentText ?? '', + ); +} + +export async function attemptWechatCursorPageTimeoutTakeover({ + error = null, + intent = null, + wechatIntent = null, + userId, + sessionId = null, + requestId, + agentRunGateway = null, + toolGatewayStatus = null, + timeoutMs = 20 * 60 * 1000, + logger = console, + env = process.env, +} = {}) { + if (String(error?.code ?? '') !== 'WECHAT_AGENT_REPLY_TIMEOUT') return null; + if (wechatIntent?.kind !== 'page.generate') return null; + if (!agentRunGateway?.createRun || !agentRunGateway?.dispatchRun) return null; + + const takeover = resolveCursorPageTimeoutTakeover({ + row: buildWechatPageTimeoutTakeoverRow(intent), + error, + toolGatewayStatus: + toolGatewayStatus + ?? resolveToolGatewayStatus(agentRunGateway, env), + env, + }); + if (!takeover) return null; + + const takeoverRequestId = `${String(requestId ?? '').trim() || cryptoRandomId()}-cursor-takeover`; + logger.info?.('[wechat-mp] cursor page timeout takeover starting', { + userId, + sessionId, + requestId: takeoverRequestId, + }); + + try { + return await executeCursorTakeoverRun({ + agentRunGateway, + userId, + sessionId, + requestId: takeoverRequestId, + userMessage: takeover.userMessage, + runOptions: takeover.runOptions, + timeoutMs, + logger, + }); + } catch (takeoverErr) { + logger.warn?.('[wechat-mp] cursor page timeout takeover failed:', takeoverErr); + return null; + } +} + +function cryptoRandomId() { + return crypto.randomUUID(); +} diff --git a/wechat-cursor-page-timeout-takeover.test.mjs b/wechat-cursor-page-timeout-takeover.test.mjs new file mode 100644 index 0000000..4635d3e --- /dev/null +++ b/wechat-cursor-page-timeout-takeover.test.mjs @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + attemptWechatCursorPageTimeoutTakeover, + buildWechatPageTimeoutTakeoverRow, +} from './wechat-cursor-page-timeout-takeover.mjs'; + +const enabledEnv = { + MEMIND_CURSOR_EXECUTOR_ENABLED: '1', + MEMIND_CURSOR_PAGE_TIMEOUT_TAKEOVER: '1', + MEMIND_AIDER_SKILL_USE_CURSOR: '1', +}; + +test('buildWechatPageTimeoutTakeoverRow uses display text from intent', () => { + const row = buildWechatPageTimeoutTakeoverRow({ + displayText: '帮我生成 google 今日热门页面', + }); + const parsed = JSON.parse(row.user_message_json); + assert.equal(parsed.metadata.displayText, '帮我生成 google 今日热门页面'); +}); + +test('attemptWechatCursorPageTimeoutTakeover skips non-page intents', async () => { + const result = await attemptWechatCursorPageTimeoutTakeover({ + error: { code: 'WECHAT_AGENT_REPLY_TIMEOUT' }, + intent: { displayText: '今天天气怎么样' }, + wechatIntent: { kind: 'chat.general' }, + userId: 'user-1', + sessionId: '20260911_7', + requestId: 'req-1', + agentRunGateway: { createRun: async () => ({ id: 'run-1' }) }, + env: enabledEnv, + }); + assert.equal(result, null); +}); + +test('attemptWechatCursorPageTimeoutTakeover dispatches cursor run on goose timeout', async () => { + const calls = []; + const agentRunGateway = { + getToolGatewayStatus: () => ({ enabled: true, executors: ['cursor'] }), + async createRun(userId, payload) { + calls.push(['createRun', userId, payload]); + return { id: 'run-cursor-1' }; + }, + dispatchRun(runId) { + calls.push(['dispatchRun', runId]); + }, + async getRunForUser() { + return { + status: 'succeeded', + error: null, + }; + }, + async listRunEventsForUser() { + return { + events: [{ + eventType: 'tool_gateway_result', + data: { + executor: 'cursor', + stdoutTail: '页面已生成:https://example.com/MindSpace/user-1/public/google-trends.html', + }, + }], + }; + }, + }; + + const result = await attemptWechatCursorPageTimeoutTakeover({ + error: { code: 'WECHAT_AGENT_REPLY_TIMEOUT' }, + intent: { displayText: '帮我生成 google 今日热门话题分析页面' }, + wechatIntent: { kind: 'page.generate' }, + userId: 'user-1', + sessionId: '20260911_7', + requestId: 'req-1', + agentRunGateway, + env: enabledEnv, + }); + + assert.ok(result?.text); + assert.match(result.text, /google-trends\.html/); + assert.equal(calls.some((call) => call[0] === 'dispatchRun'), true); + const createPayload = calls.find((call) => call[0] === 'createRun')?.[2]; + assert.equal(createPayload.userMessage.metadata.memindRun.cursorTimeoutTakeover, true); + assert.equal(createPayload.toolMode, 'code'); + assert.equal(createPayload.forceDeepReasoning, true); +}); diff --git a/wechat-mp-config.mjs b/wechat-mp-config.mjs index eeccde6..2209534 100644 --- a/wechat-mp-config.mjs +++ b/wechat-mp-config.mjs @@ -119,7 +119,7 @@ export function loadWechatMpConfig(env = process.env) { parseCsvList(env.H5_WECHAT_MP_PAGE_DATA_AIDER_REVIEW_USERS), agentReplyTimeoutMs: Math.max( 0, - Number(env.H5_WECHAT_MP_AGENT_REPLY_TIMEOUT_MS ?? 15 * 60 * 1000), + Number(env.H5_WECHAT_MP_AGENT_REPLY_TIMEOUT_MS ?? 20 * 60 * 1000), ), requireFreshPageThumbnail: env.H5_WECHAT_MP_FRESH_PAGE_THUMBNAILS !== '0', repairFreshPageThumbnail: env.H5_WECHAT_MP_FRESH_THUMBNAIL_REPAIR === '1', diff --git a/wechat-mp.mjs b/wechat-mp.mjs index c53aafb..5cadff4 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -10,6 +10,7 @@ import { reconcileAgentSession } from './session-reconcile.mjs'; import { resolveSessionAccess } from './session-broker.mjs'; import { loadWechatMpConfig } from './wechat-mp-config.mjs'; import { executeWechatCursorAgentRun } from './wechat-cursor-agent-run.mjs'; +import { attemptWechatCursorPageTimeoutTakeover } from './wechat-cursor-page-timeout-takeover.mjs'; import { resolveWechatCursorExecutorEligible } from './wechat-cursor-executor-policy.mjs'; import { isWechatCursorChannelReply, @@ -125,7 +126,7 @@ const DEFAULT_WECHAT_JSAPI_TICKET_URL = 'https://api.weixin.qq.com/cgi-bin/ticke const DEFAULT_ASR_TARGET = process.env.H5_ASR_TARGET ?? 'https://asr.tkmind.cn'; const WECHAT_RECENT_MEDIA_TTL_MS = 15 * 60 * 1000; const WECHAT_RECENT_IMAGE_MAX_COUNT = 10; -const DEFAULT_WECHAT_AGENT_REPLY_TIMEOUT_MS = 15 * 60 * 1000; +const DEFAULT_WECHAT_AGENT_REPLY_TIMEOUT_MS = 20 * 60 * 1000; const WECHAT_SESSION_RESET_ACK_TEXT = '已切换到新会话,请发送你的新需求。'; export { loadWechatMpConfig }; const PUBLIC_HTML_LINK_PATTERN = @@ -3043,36 +3044,72 @@ export function createWechatMpService({ if (wechatCursorAttempted) { await ensureSessionProvider(sessionId, user.userId, { intentKind: wechatIntent.kind }); } - reply = await executeSessionReply( - (pathname, init) => fetchForSession(sessionId, pathname, init), - sessionId, - activeRequestId, - activeAgentPrompt, - buildIntentMetadata(intent, { - mediaAnalysisEnabled, - imagePolicy, - pgRequired: isPageDataRequest, - }), - { - prepareUserMessage: (userMessage) => prepareWechatAgentUserMessage({ - userId: user.userId, - sessionId, - userMessage, - preserveAgentPrompt: sessionPageContinuation, + try { + reply = await executeSessionReply( + (pathname, init) => fetchForSession(sessionId, pathname, init), + sessionId, + activeRequestId, + activeAgentPrompt, + buildIntentMetadata(intent, { + mediaAnalysisEnabled, + imagePolicy, + pgRequired: isPageDataRequest, }), - submitReply: submitSessionReply - ? ({ requestId: replyRequestId, userMessage }) => - submitSessionReply({ - userId: user.userId, - sessionId, - requestId: replyRequestId, - userMessage, - options: { requireHistoricalImageIsolation: true }, - }) - : null, + { + prepareUserMessage: (userMessage) => prepareWechatAgentUserMessage({ + userId: user.userId, + sessionId, + userMessage, + preserveAgentPrompt: sessionPageContinuation, + }), + submitReply: submitSessionReply + ? ({ requestId: replyRequestId, userMessage }) => + submitSessionReply({ + userId: user.userId, + sessionId, + requestId: replyRequestId, + userMessage, + options: { requireHistoricalImageIsolation: true }, + }) + : null, + timeoutMs: agentReplyTimeoutMs, + }, + ); + } catch (sessionErr) { + const takeoverReply = await attemptWechatCursorPageTimeoutTakeover({ + error: sessionErr, + intent, + wechatIntent, + userId: user.userId, + sessionId, + requestId: activeRequestId, + agentRunGateway, timeoutMs: agentReplyTimeoutMs, - }, - ); + logger, + env, + }); + if (takeoverReply) { + const buildCanonicalUrl = (relativePath) => { + const base = String(config.publicBaseUrl ?? '').replace(/\/+$/, ''); + const normalized = String(relativePath ?? '') + .replace(/^\/+/, '') + .split('/') + .map(encodeURIComponent) + .join('/'); + return `${base}/MindSpace/${encodeURIComponent(user.userId)}/${normalized}`; + }; + reply = prepareWechatCursorPageDelivery({ + reply: takeoverReply, + intent, + publishDir: userPublishDir, + requestStartedAt, + buildCanonicalUrl, + topic: wechatIntent?.topic ?? intent?.agentText ?? '', + }); + } else { + throw sessionErr; + } + } } generatedImages = collectWechatGeneratedImages(replyRequestMessages(reply)); if (imagePolicy.standaloneImageMode === 'required' && generatedImages.length === 0) {