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 重连补发。
+63 -17
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,23 +99,24 @@ if (args.help) {
process.exit(0); process.exit(0);
} }
const pool = createDbPool(); async function bootstrapWorker() {
const workflowShadowEnabled = isEnabledFlag( const pool = createDbPool();
const workflowShadowEnabled = isEnabledFlag(
process.env.MEMIND_ORCHESTRATOR_SHADOW_OBSERVATION_ENABLED, process.env.MEMIND_ORCHESTRATOR_SHADOW_OBSERVATION_ENABLED,
); );
const workflowShadowObserver = workflowShadowEnabled const workflowShadowObserver = workflowShadowEnabled
? createWorkflowShadowObserver({ ? createWorkflowShadowObserver({
configService: createOrchestratorAdminConfigService(pool), configService: createOrchestratorAdminConfigService(pool),
serviceToken: process.env.MEMIND_ORCHESTRATOR_SERVICE_TOKEN, serviceToken: process.env.MEMIND_ORCHESTRATOR_SERVICE_TOKEN,
}) })
: null; : null;
const baseUserAuth = createUserAuth(pool, { const baseUserAuth = createUserAuth(pool, {
usersRoot: process.env.H5_USERS_ROOT ?? path.join(root, 'users'), usersRoot: process.env.H5_USERS_ROOT ?? path.join(root, 'users'),
h5Root: root, h5Root: root,
}); });
const overrideWorkingDir = String(process.env.MEMIND_AGENT_RUN_WORKDIR_OVERRIDE ?? '').trim(); const overrideWorkingDir = String(process.env.MEMIND_AGENT_RUN_WORKDIR_OVERRIDE ?? '').trim();
const overrideWorkingDirUserId = String(process.env.MEMIND_AGENT_RUN_WORKDIR_USER_ID ?? '').trim(); const overrideWorkingDirUserId = String(process.env.MEMIND_AGENT_RUN_WORKDIR_USER_ID ?? '').trim();
const userAuth = overrideWorkingDir const userAuth = overrideWorkingDir
? { ? {
...baseUserAuth, ...baseUserAuth,
async resolveWorkingDir(userId) { async resolveWorkingDir(userId) {
@@ -121,23 +127,59 @@ const userAuth = overrideWorkingDir
}, },
} }
: baseUserAuth; : baseUserAuth;
const llmProviderService = createLlmProviderService(pool, { const llmProviderService = createLlmProviderService(pool, {
apiTarget: process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006', apiTarget: process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006',
apiSecret: process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret', apiSecret: process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret',
}); });
const toolGateway = createToolGateway({ llmProviderService }); const memoryV2ConfigService = createMemoryV2AdminConfigService(pool);
const apiTargets = parseTargets(); const getEffectiveEnv = async () => {
const tkmindProxy = createTkmindProxy({ const state = await memoryV2ConfigService.getRuntimeState().catch(() => null);
return {
...process.env,
...(state?.overrides ?? {}),
};
};
const conversationMemoryService = createConversationMemoryService(pool, {
llmProviderService,
getEffectiveEnv,
});
const memoryV2 = await createManagedMemoryV2Runtime({
legacyMemoryService: conversationMemoryService,
configService: memoryV2ConfigService,
mysqlPool: pool,
});
const episodicMemoryService = createEpisodicMemoryService(pool, {
getEffectiveEnv,
logger: console,
});
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], apiTarget: apiTargets[0],
apiTargets, apiTargets,
apiSecret: process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret', apiSecret: process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret',
userAuth, userAuth,
}); });
const gateway = createAgentRunGateway({ const gateway = createAgentRunGateway({
pool, pool,
userAuth, userAuth,
tkmindProxy, tkmindProxy,
toolGateway, toolGateway,
chatIntentRouter,
conversationMemoryService,
autoDispatch: false, autoDispatch: false,
maxConcurrentRuns: Number(process.env.MEMIND_AGENT_RUN_QUEUE_CONCURRENCY ?? 1), maxConcurrentRuns: Number(process.env.MEMIND_AGENT_RUN_QUEUE_CONCURRENCY ?? 1),
runTimeoutMs: Number(process.env.MEMIND_AGENT_RUN_TIMEOUT_MS ?? 15 * 60 * 1000), runTimeoutMs: Number(process.env.MEMIND_AGENT_RUN_TIMEOUT_MS ?? 15 * 60 * 1000),
@@ -149,7 +191,11 @@ const gateway = createAgentRunGateway({
&& isEnabledFlag( && isEnabledFlag(
process.env.MEMIND_ORCHESTRATOR_PAGE_DATA_VALIDATION_GATE_ENABLED, 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) => {
+17 -4
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,16 +1482,19 @@ export function createTkmindProxy({
const baseSessionScopedPolicy = const baseSessionScopedPolicy =
typeof userAuth.getAgentSessionPolicy === typeof userAuth.getAgentSessionPolicy ===
'function' 'function'
? await userAuth.getAgentSessionPolicy( ? (
sessionPolicy
? resolvedSessionPolicy
: await userAuth.getAgentSessionPolicy(
userId, userId,
{ {
toolMode: toolMode:
resolvedSessionPolicy resolvedSessionPolicy
?.toolMode ?? 'chat', ?.toolMode ?? 'chat',
sessionId: session.id, ...mcpScope,
packageId: `cp_${session.id}`,
}, },
) )
)
: resolvedSessionPolicy; : resolvedSessionPolicy;
const sessionScopedPolicy = disableImageReading const sessionScopedPolicy = disableImageReading
? withoutSessionImageRead(baseSessionScopedPolicy) ? withoutSessionImageRead(baseSessionScopedPolicy)
+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;