fix(agent-run): tolerate goosed stdio MCP listing gaps during session reconcile.
Memind CI / Test, build, and release guards (push) Failing after 4s
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:
+1
-1
@@ -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 replay(docs/h5-session-architecture-20260706.md Patch 4c)
|
# H5 Session stream replay(docs/h5-session-architecture-20260706.md Patch 4c)
|
||||||
# 默认 0:session SSE 仍纯透传 goosed;设 1 时 Portal 持久化 session 事件并支持 Last-Event-ID 重连补发。
|
# 默认 0:session SSE 仍纯透传 goosed;设 1 时 Portal 持久化 session 事件并支持 Last-Event-ID 重连补发。
|
||||||
|
|||||||
+102
-56
@@ -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; });
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user