feat(wechat): hand off page.generate timeouts to Cursor after 20 minutes
Memind CI / Test, build, and release guards (push) Failing after 2m37s

Wire WECHAT_AGENT_REPLY_TIMEOUT into the same Cursor takeover path used by
agent runs, raise the default WeChat reply timeout to 20 minutes, and fall
back to env-based tool-gateway detection when runtime exports are missing.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-09-11 15:31:19 +08:00
parent cb3fb352be
commit 2c3c1f7a52
9 changed files with 352 additions and 37 deletions
+2
View File
@@ -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
+1
View File
@@ -3360,6 +3360,7 @@ export function createAgentRunGateway({
listRunEventsForUser,
dispatchRun,
getQueueStatus,
getToolGatewayStatus: () => (toolGateway?.getStatus ? toolGateway.getStatus() : null),
dispatchQueuedRuns,
recoverStaleRunningRuns,
};
+24 -1
View File
@@ -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'
+22
View File
@@ -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({
+70 -6
View File
@@ -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,
} = {}) {
+82
View File
@@ -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();
}
@@ -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);
});
+1 -1
View File
@@ -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',
+66 -29
View File
@@ -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) {