feat(wechat): hand off page.generate timeouts to Cursor after 20 minutes
Memind CI / Test, build, and release guards (push) Failing after 2m37s
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:
@@ -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
|
||||
|
||||
@@ -3360,6 +3360,7 @@ export function createAgentRunGateway({
|
||||
listRunEventsForUser,
|
||||
dispatchRun,
|
||||
getQueueStatus,
|
||||
getToolGatewayStatus: () => (toolGateway?.getStatus ? toolGateway.getStatus() : null),
|
||||
dispatchQueuedRuns,
|
||||
recoverStaleRunningRuns,
|
||||
};
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
} = {}) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user