Files
metona-ai-desktop/electron/services/agent-engine-manager.service.ts
T
thzxx 99d0c54129
CI / 类型检查 + Lint + 单元测试 (push) Failing after 6m27s
CI / 产物编译验证 (push) Successful in 9m57s
CI / 全量测试 (Electron ABI) (push) Failing after 5m19s
feat: v0.7.4 时序语义修正 · 防线实效补漏 · 全量测试翻倍 — 2406 用例 + jsdom 组件测试全量回归
P1 修复面收口:
- 超时三态区分(aborted→USER_INTERRUPT / ETIMEDOUT→TIMEOUT / 其余→ERROR),
  根治"真实网络超时被误报为用户中断"
- 流空闲超时统一(SSE/Ollama/Anthropic 读循环 60s 无数据抛 504 进重试通道)
- 同会话并发 sendMessage 防重入(isRunning 守卫)+ 会话存在性预检 +
  前置调用移入 try(ERROR+DONE 双事件保证,根治 isStreaming 假死)
- 清空审计后 resetChainCache(根治 verifyChain 误报 TAMPERED)
- DONE 不再提前清理 TRACE(TERMINATED 统一收尾,补全最终迭代录制)
- IME 合成回车不发送(普通 Enter + Cmd/Ctrl+Enter 双分支)+ handleSend 闭包修复

P2 安全纵深:
- preload 移除原始 electronAPI 暴露(渲染层零使用,关掉 XSS invoke 任意通道单点风险)
- CORS 同源回显根治(仅当前浏览页面 Origin,did-navigate 同步)
- MEMORY.md 命令保护正则扩展(括号/$/反引号/< 重定向边界 + 前导路径)
- write_file append TOCTOU 统一(open 后 realpath 校验,新文件分支补漏)
- 敏感键归一化(authKey 驼峰/连字符命中)+ MCP headers 鉴权值加密落库
- ReDoS 检测共享化(search_files/file_editor 统一拦截)
- run_tests/lint_code 升风险 + 需确认 + npx --no-install(执行边界对齐 run_command)
- MCP/SearXNG/llm.baseURL/updateFeedUrl 配置类 URL 高危目标校验(IPv6 去括号 +
  十六进制映射解析 + 尾点剥离)

P3 架构还债:
- temperature/maxTokens 热生效(引擎/编排器/SubAgent 三处接线)+ setBatch 单事务落盘
- SessionRecorder flush 竞态根治(flushPromise 等待 + 超限内联落盘 + stopRecording async)
- 内存收口(lastConsolidationBySession LRU / subTraces 清理 / 会话删除 disposeEngine)
- i18n 全量收口(28 组件 + 353 key 双字典,状态标签改渲染时函数)
- 死代码清理(updateTraceStep/HEADER_HEIGHT/void preA/失实注释)
- 斜杠菜单 MUI 化 + 删除逻辑收敛 resetSessionState + Blob URL 统一释放 +
  用户消息"仅保存"落库(saveMessage 透传前端 id 修复 id 错位)

P4 能力演进:
- 死循环检测拆分(驻留前置 + 乒乓后置带进度信号,合法交替不误报)
- run-lock 30s 超时强制 abort(旧 run 卡死不无限排队)
- RETRY 双通道 stream_reset(前端按 run 归属精确清空,根治重试文本重复)
- FTS5 trigram 中文子串搜索(迁移 9 版本化 SCHEMA_VERSION=2,≤2 字符 LIKE 回退)
- getContextWindow 兜底 1M→128K(未知模型防 413)

测试:
- 855 → 2406 用例(+1551,2.8 倍):服务层 +325(含 MemoryManager 51 新用例)、
  工具实体 +483、IPC/适配器 +390(含 OpenAI/Anthropic/Ollama 独立套件)、
  纯函数表格化 +330;引入 jsdom + @testing-library(14 组件测试文件 249 用例)
- 修复 R1(saveMessage id 透传)/ R2(stream_reset 精确归属)两个回归缺陷
- 遗留低危项清零:git-tools 顺序耦合 / web-fetch 真实时间退避 / slo 内存断言 /
  mcp-security 多余 skipIf / deepseek-balance 命名误导 / 组件 mock 注入脆弱性

版本: 0.7.4; README 同步(工具风险表/版本徽章); 依赖: 移除 @electron-toolkit/preload,
新增 jsdom/@testing-library(devDependencies 不打包)

回归: typecheck 双端 0 错误; ESLint 0/0; Electron ABI 全量 2406/2406 零跳过;
系统 Node 2110 通过 296 跳过(better-sqlite3 ABI)
2026-08-30 19:19:07 +08:00

242 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Agent Engine Manager — 每会话独立引擎管理器(P2-10)
*
* 解决原"全局单引擎"的两个缺陷:
* 1. 全局串行锁:原 AgentLoopEngine.currentRunPromise 使所有会话共享一把锁,
* 上一会话未结束时新会话消息需排队等待(最长卡 120s 工具超时)。
* 现在每个会话持有独立引擎实例,多会话可并行运行。
* 2. adapter abort 信号互踩:原所有引擎/SubAgent 共享一个 adapter 实例,
* setAbortSignal 单槽位导致并发时中断信号错乱。
* 现在创建引擎时通过 adapter 工厂为每个引擎生成独立 adapter 实例
* (adapter 是无状态的配置包装,实例化成本可忽略)。
*
* 引擎生命周期:
* - 按需创建(首次 sendMessage 时),事件统一转发到 manager(附加 sessionId
* - LRU 淘汰:缓存超过 30 个引擎时,淘汰最旧的非运行中引擎
*
* @see electron/harness/agent-loop/engine.ts — 引擎实现
*/
import { EventEmitter } from 'events';
import { AgentLoopEngine } from '../harness/agent-loop';
import type { AgentLoopConfig } from '../harness/agent-loop/types';
import type { IMetonaProviderAdapter, MetonaToolDef } from '../harness/types';
import type { ToolRegistry } from '../harness/tools/registry';
import type { PreToolHook } from '../harness/hooks/pre-tool';
import type { PostToolHook } from '../harness/hooks/post-tool';
import log from 'electron-log';
/** 引擎缓存上限(超过后淘汰最旧的非运行中引擎) */
const MAX_ENGINES = 30;
export class AgentEngineManager extends EventEmitter {
private engines = new Map<string, AgentLoopEngine>();
/** 运行中的会话(stateChange INIT 添加 / TERMINATED 移除),用于 LRU 淘汰保护 */
private running = new Set<string>();
/** 主 adapter(供 MemoryConsolidator 等共享组件使用) */
private primaryAdapter: IMetonaProviderAdapter;
private fallbackAdapter: IMetonaProviderAdapter | null = null;
private baseConfig: Partial<AgentLoopConfig>;
private workspacePath = '';
constructor(
private opts: {
/** adapter 工厂(每次调用返回新实例;闭包内读取最新配置) */
buildAdapter: () => IMetonaProviderAdapter;
baseConfig: Partial<AgentLoopConfig>;
toolRegistry?: ToolRegistry;
preToolHooks?: PreToolHook[];
postToolHooks?: PostToolHook[];
},
) {
super();
this.primaryAdapter = opts.buildAdapter();
this.baseConfig = { ...opts.baseConfig };
}
/** 主 adapter(供 consolidator / orchestrator 等共享使用) */
getAdapter(): IMetonaProviderAdapter {
return this.primaryAdapter;
}
/** 创建独立 adapter 实例(每引擎/SubAgent 独享,避免 abort 信号互踩) */
createAdapter(): IMetonaProviderAdapter {
return this.opts.buildAdapter();
}
getFallbackAdapter(): IMetonaProviderAdapter | null {
return this.fallbackAdapter;
}
/** 设置故障转移 Provider(同步到所有引擎) */
setFallbackAdapter(adapter: IMetonaProviderAdapter | null): void {
this.fallbackAdapter = adapter;
for (const engine of this.engines.values()) {
engine.setFallbackAdapter(adapter);
}
}
getWorkspacePath(): string {
return this.workspacePath;
}
setWorkspacePath(path: string): void {
this.workspacePath = path;
for (const engine of this.engines.values()) {
engine.setWorkspacePath(path);
}
}
/** 同步工具列表到所有引擎(工具开关变更 / MCP 注册完成时) */
setToolsAll(tools: MetonaToolDef[]): void {
for (const engine of this.engines.values()) {
engine.setTools(tools);
}
}
/** 热更新所有引擎配置(设置变更时) */
updateConfigAll(partial: Partial<AgentLoopConfig>): void {
this.baseConfig = { ...this.baseConfig, ...partial };
for (const engine of this.engines.values()) {
engine.updateConfig(partial);
}
}
/**
* 重建所有 adapterLLM 配置变更时由 reloadAdapter 调用)
* 工厂闭包读取最新配置,重建 primary + 各引擎独立实例。
*/
refreshAdapters(): void {
this.primaryAdapter = this.opts.buildAdapter();
for (const engine of this.engines.values()) {
engine.setAdapter(this.createAdapter());
engine.setFallbackAdapter(this.fallbackAdapter);
}
}
/** 获取(或创建)会话引擎 */
getEngine(sessionId: string): AgentLoopEngine {
let engine = this.engines.get(sessionId);
if (!engine) {
engine = this.createEngine(sessionId);
this.engines.set(sessionId, engine);
this.evictIdleEngines();
}
return engine;
}
/**
* v0.7.4 P1-3: 会话当前是否有 run 进行中。
* 供 agent:sendMessage 做同会话并发防重入(第二个 invoke 在第一个 run 未结束时
* 直接拒绝,而非让同一引擎并行 runStream 或排队 30s 后强制 abort)。
*/
isRunning(sessionId: string): boolean {
return this.running.has(sessionId);
}
/**
* v0.7.4 P3-5: 会话删除/归档时显式销毁引擎(内存收口)。
* 旧实现引擎只靠 LRU 上限 30 淘汰,会话删除后引擎与 adapter 实例仍驻留内存。
* 调用方在 sessions:delete 时联动调用;正在运行中的会话由调用方先 abort。
*/
disposeEngine(sessionId: string): void {
const engine = this.engines.get(sessionId);
if (!engine) return;
engine.destroy();
this.engines.delete(sessionId);
this.running.delete(sessionId);
log.debug(`[EngineManager] disposed engine for session ${sessionId}`);
}
/** 中断指定会话引擎 */
abort(sessionId: string): void {
this.engines.get(sessionId)?.abort();
}
/** 等待指定会话当前 run 结束 */
async waitForAbort(sessionId: string, timeoutMs = 5_000): Promise<boolean> {
return this.engines.get(sessionId)?.waitForAbort(timeoutMs) ?? true;
}
/** 当前引擎数量(测试/诊断用) */
get size(): number {
return this.engines.size;
}
// ===== 私有方法 =====
private createEngine(sessionId: string): AgentLoopEngine {
const engine = new AgentLoopEngine(
{
...this.baseConfig,
contextWindow: this.primaryAdapter.getContextWindow(),
},
this.createAdapter(),
this.opts.toolRegistry,
this.opts.preToolHooks ?? [],
this.opts.postToolHooks ?? [],
);
engine.setFallbackAdapter(this.fallbackAdapter);
if (this.workspacePath) engine.setWorkspacePath(this.workspacePath);
// v0.5.2 关键修复: 新建引擎从 registry 拉取当前启用工具。
// setToolsAll 只同步"已存在"的引擎 — 引擎是懒创建的(首次 sendMessage 时 getEngine),
// 启动期的 setToolsAll 调用时 engines Map 为空,全是 no-op。
// 此前缺此调用 → 新引擎 this.tools=[] → LLM 请求不带 tools →
// 模型无法发起 tool_call(症状:模型口头说要调工具,实际不调,凭历史记忆瞎编)。
// v0.4.0 P2-10 引入每会话引擎时遗留的回归,v0.5.2 修复。
if (this.opts.toolRegistry) {
engine.setTools(this.opts.toolRegistry.listTools());
}
this.forwardEngineEvents(engine, sessionId);
log.debug(
`[EngineManager] engine created for session ${sessionId} (total: ${this.engines.size + 1})`,
);
return engine;
}
/** 将引擎事件转发到 manager(统一附加 sessionId,供常驻监听器消费) */
private forwardEngineEvents(engine: AgentLoopEngine, sessionId: string): void {
engine.on('streamEvent', (event) => {
this.emit('streamEvent', { ...event, sessionId: event.sessionId || sessionId });
});
engine.on('stateChange', (data) => {
const payload = { ...data, sessionId: data.sessionId || sessionId };
// 维护运行中集合(LRU 淘汰保护)
if (payload.state === 'INIT' || payload.current === 'INIT') this.running.add(sessionId);
if (payload.state === 'TERMINATED' || payload.current === 'TERMINATED')
this.running.delete(sessionId);
this.emit('stateChange', payload);
});
engine.on('complete', (data) => {
this.running.delete(sessionId);
this.emit('complete', { ...data, sessionId: data.sessionId || sessionId });
});
engine.on('compressed', (data) => {
this.emit('compressed', { ...data, sessionId });
});
engine.on('deadLoop', (data) => {
this.emit('deadLoop', { ...data, sessionId: data.sessionId || sessionId });
});
engine.on('providerSwitched', (data) => {
this.emit('providerSwitched', { ...data, sessionId: data.sessionId || sessionId });
});
engine.on('aborted', () => {
this.emit('aborted', { sessionId });
});
}
/** LRU 淘汰:超过上限时删除最旧的非运行中引擎 */
private evictIdleEngines(): void {
if (this.engines.size <= MAX_ENGINES) return;
let toEvict = this.engines.size - MAX_ENGINES;
for (const [sessionId, engine] of this.engines) {
if (toEvict <= 0) break;
if (this.running.has(sessionId)) continue;
engine.destroy();
this.engines.delete(sessionId);
log.debug(`[EngineManager] evicted idle engine for session ${sessionId}`);
toEvict--;
}
}
}