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_TIMEOUT_MS=1200
# 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
# 默认 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 { fileURLToPath } from 'node:url';
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 { createEpisodicMemoryService } from '../episodic-memory.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 { createToolGateway } from '../tool-gateway.mjs';
import { createUserAuth } from '../user-auth.mjs';
@@ -94,62 +99,103 @@ if (args.help) {
process.exit(0);
}
const pool = createDbPool();
const workflowShadowEnabled = isEnabledFlag(
process.env.MEMIND_ORCHESTRATOR_SHADOW_OBSERVATION_ENABLED,
);
const workflowShadowObserver = workflowShadowEnabled
? createWorkflowShadowObserver({
configService: createOrchestratorAdminConfigService(pool),
serviceToken: process.env.MEMIND_ORCHESTRATOR_SERVICE_TOKEN,
})
: null;
const baseUserAuth = createUserAuth(pool, {
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 userAuth = overrideWorkingDir
? {
...baseUserAuth,
async resolveWorkingDir(userId) {
if (!overrideWorkingDirUserId || overrideWorkingDirUserId === userId) {
return path.resolve(overrideWorkingDir);
}
return baseUserAuth.resolveWorkingDir(userId);
},
}
: baseUserAuth;
const llmProviderService = createLlmProviderService(pool, {
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 tkmindProxy = createTkmindProxy({
apiTarget: apiTargets[0],
apiTargets,
apiSecret: process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret',
userAuth,
});
const gateway = createAgentRunGateway({
pool,
userAuth,
tkmindProxy,
toolGateway,
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,
),
});
async function bootstrapWorker() {
const pool = createDbPool();
const workflowShadowEnabled = isEnabledFlag(
process.env.MEMIND_ORCHESTRATOR_SHADOW_OBSERVATION_ENABLED,
);
const workflowShadowObserver = workflowShadowEnabled
? createWorkflowShadowObserver({
configService: createOrchestratorAdminConfigService(pool),
serviceToken: process.env.MEMIND_ORCHESTRATOR_SERVICE_TOKEN,
})
: null;
const baseUserAuth = createUserAuth(pool, {
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 userAuth = overrideWorkingDir
? {
...baseUserAuth,
async resolveWorkingDir(userId) {
if (!overrideWorkingDirUserId || overrideWorkingDirUserId === userId) {
return path.resolve(overrideWorkingDir);
}
return baseUserAuth.resolveWorkingDir(userId);
},
}
: baseUserAuth;
const llmProviderService = createLlmProviderService(pool, {
apiTarget: process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006',
apiSecret: process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret',
});
const memoryV2ConfigService = createMemoryV2AdminConfigService(pool);
const getEffectiveEnv = async () => {
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],
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;
process.on('SIGINT', () => { stopping = true; });
+27
View File
@@ -66,6 +66,16 @@ export function extensionsNeedingRefresh(currentExtensions, desiredExtensions) {
continue;
}
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);
toAdd.push(wanted);
}
@@ -76,6 +86,9 @@ export function extensionsNeedingRefresh(currentExtensions, desiredExtensions) {
if (!name) continue;
const exists = current.some((ext) => extensionName(ext) === name);
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);
}
}
@@ -83,6 +96,17 @@ export function extensionsNeedingRefresh(currentExtensions, desiredExtensions) {
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) {
const current = currentExtensions ?? [];
const desired = desiredExtensions ?? [];
@@ -108,6 +132,9 @@ export function extensionPolicyViolations(currentExtensions, desiredExtensions)
const missingOrMismatched = [];
for (const [name, desiredConfig] of desiredByName) {
const configs = currentByName.get(name) ?? [];
if (stdioListingOmissionAllowed(configs[0] ?? null, desiredConfig)) {
continue;
}
if (
configs.length !== 1
|| !extensionConfigsMatch(configs[0], desiredConfig)
+48
View File
@@ -122,6 +122,24 @@ test('extensionsNeedingRefresh adds missing extensions', () => {
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', () => {
assert.deepEqual(
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 () => {
const calls = [];
const apiFetch = async (pathname) => {
+23 -10
View File
@@ -1438,7 +1438,17 @@ export function createTkmindProxy({
} = {},
) {
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
? withoutSessionImageRead(baseSessionPolicy)
: baseSessionPolicy;
@@ -1472,15 +1482,18 @@ export function createTkmindProxy({
const baseSessionScopedPolicy =
typeof userAuth.getAgentSessionPolicy ===
'function'
? await userAuth.getAgentSessionPolicy(
userId,
{
toolMode:
resolvedSessionPolicy
?.toolMode ?? 'chat',
sessionId: session.id,
packageId: `cp_${session.id}`,
},
? (
sessionPolicy
? resolvedSessionPolicy
: await userAuth.getAgentSessionPolicy(
userId,
{
toolMode:
resolvedSessionPolicy
?.toolMode ?? 'chat',
...mcpScope,
},
)
)
: resolvedSessionPolicy;
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);
await result.task;
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);
} finally {
crypto.randomUUID = originalRandomUuid;