fix(agent-run): tolerate goosed stdio MCP listing gaps during session reconcile.
Memind CI / Test, build, and release guards (push) Failing after 4s

Skip hot-adding stdio extensions goosed omits from /extensions, stabilize MCP
scope during session start, and wire chat intent routing into the agent-run worker.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-08-01 20:02:33 +08:00
parent 9d78927d1b
commit cdc265d7e0
6 changed files with 202 additions and 68 deletions
+1 -1
View File
@@ -166,7 +166,7 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:5173
# MEMIND_CHAT_ROUTER_MIN_CONFIDENCE=0.65 # MEMIND_CHAT_ROUTER_MIN_CONFIDENCE=0.65
# MEMIND_CHAT_ROUTER_TIMEOUT_MS=1200 # MEMIND_CHAT_ROUTER_TIMEOUT_MS=1200
# MEMIND_CHAT_ROUTER_MODEL_PROVIDER_KEY_ID=<h5_llm_provider_keys.id> # MEMIND_CHAT_ROUTER_MODEL_PROVIDER_KEY_ID=<h5_llm_provider_keys.id>
# MEMIND_CHAT_ROUTER_MODEL=qwen2.5:3b # MEMIND_CHAT_ROUTER_MODEL=deepseek-v4-pro
# H5 Session stream replaydocs/h5-session-architecture-20260706.md Patch 4c # H5 Session stream replaydocs/h5-session-architecture-20260706.md Patch 4c
# 默认 0session SSE 仍纯透传 goosed;设 1 时 Portal 持久化 session 事件并支持 Last-Event-ID 重连补发。 # 默认 0session SSE 仍纯透传 goosed;设 1 时 Portal 持久化 session 事件并支持 Last-Event-ID 重连补发。
+102 -56
View File
@@ -3,8 +3,13 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { createAgentRunGateway } from '../agent-run-gateway.mjs'; import { createAgentRunGateway } from '../agent-run-gateway.mjs';
import { createManagedChatIntentRouter } from '../chat-intent-router.mjs';
import { createConversationMemoryService } from '../conversation-memory.mjs';
import { createDbPool } from '../db.mjs'; import { createDbPool } from '../db.mjs';
import { createEpisodicMemoryService } from '../episodic-memory.mjs';
import { createLlmProviderService } from '../llm-providers.mjs'; import { createLlmProviderService } from '../llm-providers.mjs';
import { createMemoryV2AdminConfigService } from '../memory-v2-admin-config.mjs';
import { createManagedMemoryV2Runtime } from '../memory-v2-runtime.mjs';
import { createTkmindProxy } from '../tkmind-proxy.mjs'; import { createTkmindProxy } from '../tkmind-proxy.mjs';
import { createToolGateway } from '../tool-gateway.mjs'; import { createToolGateway } from '../tool-gateway.mjs';
import { createUserAuth } from '../user-auth.mjs'; import { createUserAuth } from '../user-auth.mjs';
@@ -94,62 +99,103 @@ if (args.help) {
process.exit(0); process.exit(0);
} }
const pool = createDbPool(); async function bootstrapWorker() {
const workflowShadowEnabled = isEnabledFlag( const pool = createDbPool();
process.env.MEMIND_ORCHESTRATOR_SHADOW_OBSERVATION_ENABLED, const workflowShadowEnabled = isEnabledFlag(
); process.env.MEMIND_ORCHESTRATOR_SHADOW_OBSERVATION_ENABLED,
const workflowShadowObserver = workflowShadowEnabled );
? createWorkflowShadowObserver({ const workflowShadowObserver = workflowShadowEnabled
configService: createOrchestratorAdminConfigService(pool), ? createWorkflowShadowObserver({
serviceToken: process.env.MEMIND_ORCHESTRATOR_SERVICE_TOKEN, configService: createOrchestratorAdminConfigService(pool),
}) serviceToken: process.env.MEMIND_ORCHESTRATOR_SERVICE_TOKEN,
: null; })
const baseUserAuth = createUserAuth(pool, { : null;
usersRoot: process.env.H5_USERS_ROOT ?? path.join(root, 'users'), const baseUserAuth = createUserAuth(pool, {
h5Root: root, usersRoot: process.env.H5_USERS_ROOT ?? path.join(root, 'users'),
}); h5Root: root,
const overrideWorkingDir = String(process.env.MEMIND_AGENT_RUN_WORKDIR_OVERRIDE ?? '').trim(); });
const overrideWorkingDirUserId = String(process.env.MEMIND_AGENT_RUN_WORKDIR_USER_ID ?? '').trim(); const overrideWorkingDir = String(process.env.MEMIND_AGENT_RUN_WORKDIR_OVERRIDE ?? '').trim();
const userAuth = overrideWorkingDir const overrideWorkingDirUserId = String(process.env.MEMIND_AGENT_RUN_WORKDIR_USER_ID ?? '').trim();
? { const userAuth = overrideWorkingDir
...baseUserAuth, ? {
async resolveWorkingDir(userId) { ...baseUserAuth,
if (!overrideWorkingDirUserId || overrideWorkingDirUserId === userId) { async resolveWorkingDir(userId) {
return path.resolve(overrideWorkingDir); if (!overrideWorkingDirUserId || overrideWorkingDirUserId === userId) {
} return path.resolve(overrideWorkingDir);
return baseUserAuth.resolveWorkingDir(userId); }
}, return baseUserAuth.resolveWorkingDir(userId);
} },
: baseUserAuth; }
const llmProviderService = createLlmProviderService(pool, { : baseUserAuth;
apiTarget: process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006', const llmProviderService = createLlmProviderService(pool, {
apiSecret: process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret', apiTarget: process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006',
}); apiSecret: process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret',
const toolGateway = createToolGateway({ llmProviderService }); });
const apiTargets = parseTargets(); const memoryV2ConfigService = createMemoryV2AdminConfigService(pool);
const tkmindProxy = createTkmindProxy({ const getEffectiveEnv = async () => {
apiTarget: apiTargets[0], const state = await memoryV2ConfigService.getRuntimeState().catch(() => null);
apiTargets, return {
apiSecret: process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret', ...process.env,
userAuth, ...(state?.overrides ?? {}),
}); };
const gateway = createAgentRunGateway({ };
pool, const conversationMemoryService = createConversationMemoryService(pool, {
userAuth, llmProviderService,
tkmindProxy, getEffectiveEnv,
toolGateway, });
autoDispatch: false, const memoryV2 = await createManagedMemoryV2Runtime({
maxConcurrentRuns: Number(process.env.MEMIND_AGENT_RUN_QUEUE_CONCURRENCY ?? 1), legacyMemoryService: conversationMemoryService,
runTimeoutMs: Number(process.env.MEMIND_AGENT_RUN_TIMEOUT_MS ?? 15 * 60 * 1000), configService: memoryV2ConfigService,
observeWorkflowRun: workflowShadowObserver, mysqlPool: pool,
observeWorkflowValidation: });
workflowShadowObserver?.observeValidation ?? null, const episodicMemoryService = createEpisodicMemoryService(pool, {
enforcePageDataWorkflowValidation: getEffectiveEnv,
workflowShadowEnabled logger: console,
&& isEnabledFlag( });
process.env.MEMIND_ORCHESTRATOR_PAGE_DATA_VALIDATION_GATE_ENABLED, await episodicMemoryService.ensureSchema().catch((err) => {
), console.warn(
}); '[agent-run-worker] episodic memory schema setup degraded:',
err instanceof Error ? err.message : err,
);
});
const chatIntentRouter = createManagedChatIntentRouter({
llmProviderService,
memoryV2,
conversationMemoryService,
episodicMemoryService,
configService: memoryV2ConfigService,
});
const toolGateway = createToolGateway({ llmProviderService });
const apiTargets = parseTargets();
const tkmindProxy = createTkmindProxy({
apiTarget: apiTargets[0],
apiTargets,
apiSecret: process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret',
userAuth,
});
const gateway = createAgentRunGateway({
pool,
userAuth,
tkmindProxy,
toolGateway,
chatIntentRouter,
conversationMemoryService,
autoDispatch: false,
maxConcurrentRuns: Number(process.env.MEMIND_AGENT_RUN_QUEUE_CONCURRENCY ?? 1),
runTimeoutMs: Number(process.env.MEMIND_AGENT_RUN_TIMEOUT_MS ?? 15 * 60 * 1000),
observeWorkflowRun: workflowShadowObserver,
observeWorkflowValidation:
workflowShadowObserver?.observeValidation ?? null,
enforcePageDataWorkflowValidation:
workflowShadowEnabled
&& isEnabledFlag(
process.env.MEMIND_ORCHESTRATOR_PAGE_DATA_VALIDATION_GATE_ENABLED,
),
});
return { pool, gateway };
}
const { pool, gateway } = await bootstrapWorker();
let stopping = false; let stopping = false;
process.on('SIGINT', () => { stopping = true; }); process.on('SIGINT', () => { stopping = true; });
+27
View File
@@ -66,6 +66,16 @@ export function extensionsNeedingRefresh(currentExtensions, desiredExtensions) {
continue; continue;
} }
if (!extensionConfigsMatch(ext, wanted)) { if (!extensionConfigsMatch(ext, wanted)) {
const currentExec = extensionExecutionConfig(ext);
const wantedExec = extensionExecutionConfig(wanted);
const stdioCmdArgsMatch = currentExec.type === 'stdio'
&& wantedExec.type === 'stdio'
&& currentExec.cmd === wantedExec.cmd
&& JSON.stringify(currentExec.args) === JSON.stringify(wantedExec.args);
if (stdioCmdArgsMatch) {
// goosed cannot hot-refresh stdio MCP env/tool grants after session start.
continue;
}
toRemove.push(name); toRemove.push(name);
toAdd.push(wanted); toAdd.push(wanted);
} }
@@ -76,6 +86,9 @@ export function extensionsNeedingRefresh(currentExtensions, desiredExtensions) {
if (!name) continue; if (!name) continue;
const exists = current.some((ext) => extensionName(ext) === name); const exists = current.some((ext) => extensionName(ext) === name);
if (!exists) { if (!exists) {
// goosed may omit active stdio MCP extensions from the session listing
// even though /agent/start already loaded them; do not hot-add stdio.
if (String(config?.type ?? '') === 'stdio') continue;
toAdd.push(config); toAdd.push(config);
} }
} }
@@ -83,6 +96,17 @@ export function extensionsNeedingRefresh(currentExtensions, desiredExtensions) {
return { toRemove, toAdd }; return { toRemove, toAdd };
} }
function stdioListingOmissionAllowed(currentExt, desiredConfig) {
if (String(desiredConfig?.type ?? '') !== 'stdio') return false;
if (!currentExt) return true;
const currentExec = extensionExecutionConfig(currentExt);
const desiredExec = extensionExecutionConfig(desiredConfig);
return currentExec.type === 'stdio'
&& desiredExec.type === 'stdio'
&& currentExec.cmd === desiredExec.cmd
&& JSON.stringify(currentExec.args) === JSON.stringify(desiredExec.args);
}
export function extensionPolicyViolations(currentExtensions, desiredExtensions) { export function extensionPolicyViolations(currentExtensions, desiredExtensions) {
const current = currentExtensions ?? []; const current = currentExtensions ?? [];
const desired = desiredExtensions ?? []; const desired = desiredExtensions ?? [];
@@ -108,6 +132,9 @@ export function extensionPolicyViolations(currentExtensions, desiredExtensions)
const missingOrMismatched = []; const missingOrMismatched = [];
for (const [name, desiredConfig] of desiredByName) { for (const [name, desiredConfig] of desiredByName) {
const configs = currentByName.get(name) ?? []; const configs = currentByName.get(name) ?? [];
if (stdioListingOmissionAllowed(configs[0] ?? null, desiredConfig)) {
continue;
}
if ( if (
configs.length !== 1 configs.length !== 1
|| !extensionConfigsMatch(configs[0], desiredConfig) || !extensionConfigsMatch(configs[0], desiredConfig)
+48
View File
@@ -122,6 +122,24 @@ test('extensionsNeedingRefresh adds missing extensions', () => {
assert.equal(toAdd[0].name, 'summon'); assert.equal(toAdd[0].name, 'summon');
}); });
test('extensionsNeedingRefresh does not hot-add missing stdio extensions', () => {
const { toRemove, toAdd } = extensionsNeedingRefresh(
[{ name: 'developer', available_tools: ['read_image'] }],
[
{ name: 'developer', available_tools: ['read_image'] },
{
type: 'stdio',
name: 'sandbox-fs',
cmd: '/usr/local/bin/node',
args: ['/opt/portal/mindspace-sandbox-mcp.mjs', '/tmp/user-1'],
available_tools: ['read_file'],
},
],
);
assert.deepEqual(toRemove, []);
assert.deepEqual(toAdd, []);
});
test('extensionPolicyViolations reports unexpected, duplicate, and mismatched extensions', () => { test('extensionPolicyViolations reports unexpected, duplicate, and mismatched extensions', () => {
assert.deepEqual( assert.deepEqual(
extensionPolicyViolations( extensionPolicyViolations(
@@ -143,6 +161,36 @@ test('extensionPolicyViolations reports unexpected, duplicate, and mismatched ex
); );
}); });
test('extensionPolicyViolations ignores stdio extensions omitted from goosed listing', () => {
assert.deepEqual(
extensionPolicyViolations(
[{ name: 'developer', available_tools: ['read_image'] }],
[
{ name: 'developer', available_tools: ['read_image'] },
{
type: 'stdio',
name: 'sandbox-fs',
cmd: '/usr/local/bin/node',
args: ['/opt/portal/mindspace-sandbox-mcp.mjs', '/tmp/user-1'],
available_tools: ['read_file'],
},
{
type: 'stdio',
name: 'tkmind-search',
cmd: '/usr/local/bin/node',
args: ['/opt/portal/tkmind-search-mcp.mjs'],
available_tools: ['search_external'],
},
],
),
{
unexpected: [],
duplicate: [],
missingOrMismatched: [],
},
);
});
test('reconcileAgentSession tolerates invalid working directory during resume sync without restart', async () => { test('reconcileAgentSession tolerates invalid working directory during resume sync without restart', async () => {
const calls = []; const calls = [];
const apiFetch = async (pathname) => { const apiFetch = async (pathname) => {
+23 -10
View File
@@ -1438,7 +1438,17 @@ export function createTkmindProxy({
} = {}, } = {},
) { ) {
const resolvedWorkingDir = workingDir ?? await userAuth.resolveWorkingDir(userId); const resolvedWorkingDir = workingDir ?? await userAuth.resolveWorkingDir(userId);
const baseSessionPolicy = sessionPolicy ?? await userAuth.getAgentSessionPolicy(userId); const mcpScopeSessionId = crypto.randomUUID();
const mcpScope = sessionPolicy
? null
: {
sessionId: mcpScopeSessionId,
packageId: `cp_${mcpScopeSessionId}`,
};
const baseSessionPolicy = sessionPolicy ?? await userAuth.getAgentSessionPolicy(
userId,
mcpScope ?? {},
);
const resolvedSessionPolicy = disableImageReading const resolvedSessionPolicy = disableImageReading
? withoutSessionImageRead(baseSessionPolicy) ? withoutSessionImageRead(baseSessionPolicy)
: baseSessionPolicy; : baseSessionPolicy;
@@ -1472,15 +1482,18 @@ export function createTkmindProxy({
const baseSessionScopedPolicy = const baseSessionScopedPolicy =
typeof userAuth.getAgentSessionPolicy === typeof userAuth.getAgentSessionPolicy ===
'function' 'function'
? await userAuth.getAgentSessionPolicy( ? (
userId, sessionPolicy
{ ? resolvedSessionPolicy
toolMode: : await userAuth.getAgentSessionPolicy(
resolvedSessionPolicy userId,
?.toolMode ?? 'chat', {
sessionId: session.id, toolMode:
packageId: `cp_${session.id}`, resolvedSessionPolicy
}, ?.toolMode ?? 'chat',
...mcpScope,
},
)
) )
: resolvedSessionPolicy; : resolvedSessionPolicy;
const sessionScopedPolicy = disableImageReading const sessionScopedPolicy = disableImageReading
+1 -1
View File
@@ -2504,7 +2504,7 @@ test('wechat mp service reconciles existing dedicated session before reply', asy
assert.equal(result.status, 200); assert.equal(result.status, 200);
await result.task; await result.task;
assert.equal(calls.some(([pathname]) => pathname === '/agent/update_working_dir'), true); assert.equal(calls.some(([pathname]) => pathname === '/agent/update_working_dir'), true);
assert.equal(calls.some(([pathname]) => pathname === '/agent/add_extension'), true); assert.equal(calls.some(([pathname]) => pathname === '/agent/add_extension'), false);
assert.equal(calls.some(([pathname]) => pathname === '/sessions/session-1/reply'), true); assert.equal(calls.some(([pathname]) => pathname === '/sessions/session-1/reply'), true);
} finally { } finally {
crypto.randomUUID = originalRandomUuid; crypto.randomUUID = originalRandomUuid;