feat: v0.7.4 时序语义修正 · 防线实效补漏 · 全量测试翻倍 — 2406 用例 + jsdom 组件测试全量回归
CI / 类型检查 + Lint + 单元测试 (push) Failing after 6m27s
CI / 产物编译验证 (push) Successful in 9m57s
CI / 全量测试 (Electron ABI) (push) Failing after 5m19s

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)
This commit is contained in:
2026-08-30 19:19:07 +08:00
parent ebe45482b0
commit 99d0c54129
137 changed files with 25190 additions and 1792 deletions
+190 -55
View File
@@ -94,6 +94,16 @@ export class AgentLoopEngine extends EventEmitter {
/** v0.3.0: 工具调用签名历史(用于死循环检测) */
private toolCallHistory: string[] = [];
/**
* v0.7.4 P4-1: 每轮工具执行结果的签名历史(与 toolCallHistory 按轮一一对应)。
* 用于乒乓模式(ABAB)的"进度信号"二次确认——若 ABAB 周期内两次 A 的调用与
* 结果均相同、两次 B 的调用与结果均相同,说明模型在完全相同的操作间空转,
* 判定死循环;若结果有差异(模型获得了新信息),放行避免误报合法交替工作流
* (如"读→写→读验证→写修复"在写入内容变化时本身不构成 ABAB,此处防御
* 结果变化但参数不变的外界状态型交替)。
*/
private toolResultHistory: string[] = [];
constructor(
config: Partial<AgentLoopConfig> = {},
private adapter: IMetonaProviderAdapter,
@@ -139,7 +149,8 @@ export class AgentLoopEngine extends EventEmitter {
* #3 修复: 从 adapter 同步 contextWindow 到 Engine 配置
*
* Engine 的 DEFAULT_CONFIG.contextWindow 硬编码为 128_000,但各 Provider 实际支持的
* 上下文窗口差异巨大(DeepSeek 64K / Agnes 1M / Ollama 4096)。
* 上下文窗口差异巨大(DeepSeek 1M / Agnes 1M / MiMo 1M / OpenAI 128K~1M /
* Anthropic 200K / Ollama 4096 起,v0.7.4 修正注释——此前误写 64K)。
* 不同步会导致压缩阈值(compressionThreshold * contextWindow)计算错误。
*/
private syncContextWindow(): void {
@@ -183,8 +194,43 @@ export class AgentLoopEngine extends EventEmitter {
systemPrompt: MetonaSystemPrompt,
): Promise<AgentLoopOutput> {
// C-4/H-6: 等待上一次 run 完全结束,防止并发 run 污染状态和旧 DONE 中断新流
// v0.7.4 P4-2: 等待加 30s 超时——上一 run 若卡在流空闲/不响应 abort 的环节
// (P1-2 已加流空闲超时兜底,此处为最后一道防线),新消息不再无限排队。
// 超时后强制 abort 旧 run 并再等待 5s;仍不结束则抛错(上层保证 ERROR+DONE 收尾)。
if (this.currentRunPromise) {
await this.currentRunPromise.catch(() => {});
const prevRun = this.currentRunPromise;
let timedOut = false;
let timerHandle: NodeJS.Timeout | undefined;
const timer = new Promise<void>((resolve) => {
timerHandle = setTimeout(() => {
timedOut = true;
resolve();
}, 30_000);
});
try {
await Promise.race([prevRun.catch(() => {}), timer]);
} finally {
if (timerHandle) clearTimeout(timerHandle);
}
if (timedOut) {
log.warn(
'[AgentLoop] Previous run did not finish within 30s — forcing abort before new run',
);
this.abort();
const forced = await Promise.race([
prevRun.catch(() => {}).then(() => true),
new Promise<boolean>((resolve) => {
const t = setTimeout(() => resolve(false), 5_000);
// 避免 timer 悬挂
(t as unknown as { unref?: () => void }).unref?.();
}),
]);
if (!forced) {
throw new Error(
'Previous run did not finish even after force abort (5s). Please restart the app or wait for the stuck operation to complete.',
);
}
}
}
this.currentRunPromise = this.executeRunStream(userMessage, sessionId, history, systemPrompt);
try {
@@ -221,6 +267,8 @@ export class AgentLoopEngine extends EventEmitter {
this.eventSeq = 0;
// v0.3.0: 重置工具调用历史(用于死循环检测)
this.toolCallHistory = [];
// v0.7.4 P4-1: 重置工具结果历史(进度信号,与调用历史按轮对应)
this.toolResultHistory = [];
try {
await this.transitionTo(AgentLoopState.INIT);
@@ -332,14 +380,23 @@ export class AgentLoopEngine extends EventEmitter {
// P2-9 修复: toLowerCase 避免大小写敏感导致超时误判为 ERROR
// Node fetch 超时错误 "The operation timed out" / abort "Aborted" 都需覆盖
const errMsgLower = errMsg.toLowerCase();
if (
this.aborted ||
errMsgLower.includes('aborted') ||
errMsgLower.includes('timed out') ||
errMsgLower.includes('timeout')
) {
// v0.7.4 P1-1: 区分三态,修复"真实网络超时被误报为 USER_INTERRUPT"。
// 旧实现把 'timeout'/'timed out' 一律映射 USER_INTERRUPT,但 fetchWithTimeout
// 的超时错误("Request timed out after ...")在重试耗尽后也会命中——用户并未
// 中断,前端却显示"用户中断"语义,SLO 统计与错误诊断全部失真。
// 判定顺序:用户主动中断 > 真实超时 > 其余错误。
if (this.aborted) {
return this.finish(TerminationReason.USER_INTERRUPT);
}
const errCode = (error as { code?: string }).code;
const isTimeout =
errCode === 'ETIMEDOUT' ||
errCode === 'UND_ERR_CONNECT_TIMEOUT' ||
errMsgLower.includes('timed out') ||
errMsgLower.includes('timeout');
if (isTimeout) {
return this.finish(TerminationReason.TIMEOUT, undefined, error as Error);
}
// v0.3.0 修复: 不使用 emit('error') — Node EventEmitter 对无监听器的 'error' 事件会同步 throw
// 导致 finish() 被中断、DONE 事件丢失、前端卡死。改为日志记录,让 finish 正常执行
log.error(`[AgentLoop] Run failed: ${errMsg}`);
@@ -453,6 +510,10 @@ export class AgentLoopEngine extends EventEmitter {
// 过滤掉 RETRY 类型的 ERROR 事件 — 不转发到前端,避免触发虚假错误 UI
// RETRY 事件仅用于 Engine 内部清空缓冲区(见下方 switch 分支)
// H-11 修复: 使用 MetonaErrorCode.RETRY 替代 'as string' 强制转换,确保类型安全
// v0.7.4 P4-3: 双通道 — 内部清空缓冲的同时,向渲染层广播 STREAM_RESET 信号,
// 前端据此清空该 runId 已累积的文本/思考增量(重试会从头重建整个响应),
// 根治"第一段文本 + 重试后第二段文本"拼接重复(依赖 seq 无法去重,
// 因为 adapter 侧 seq 每次从 0 重启)。
if (
event.type === MetonaStreamEventType.ERROR &&
event.error?.code === MetonaErrorCode.RETRY
@@ -461,6 +522,15 @@ export class AgentLoopEngine extends EventEmitter {
fullContent = '';
reasoningContent = '';
toolCallsBuffer.clear();
this.emit('streamEvent', {
type: MetonaStreamEventType.STREAM_RESET,
requestId: event.requestId,
sessionId: event.sessionId,
iteration: event.iteration,
seq: this.nextSeq(),
timestamp: Date.now(),
runId: this.runId,
});
continue;
}
@@ -550,8 +620,11 @@ export class AgentLoopEngine extends EventEmitter {
// v0.3.0 修复: 死循环检测 — 在 PARSING 阶段完成后、EXECUTING 阶段开始前检测
// 确保第3轮重复调用的副作用不会产生(工具尚未执行)
// v0.7.4 P4-1 拆分: 此处仅做"驻留模式"(连续 3 轮相同签名,无结果依赖,
// 可在执行前安全判定防副作用);"乒乓模式"(ABAB + 进度信号)需要本轮
// 工具结果做二次确认,移至 EXECUTING 之后(OBSERVING 前)检测。
if (step.toolCalls && step.toolCalls.length > 0) {
if (this.detectDeadLoop(step.toolCalls)) {
if (this.detectStuckLoop(step.toolCalls)) {
log.warn(
`[AgentLoop] Dead loop detected at iteration ${this.currentIteration} (before tool execution)`,
);
@@ -615,6 +688,26 @@ export class AgentLoopEngine extends EventEmitter {
});
}
// v0.7.4 P4-1: 乒乓模式死循环检测(ABAB + 进度信号二次确认)——
// 移至 EXECUTING 之后:乒乓判定需要本轮工具**结果**做进度比对
// (两次 A 的结果是否相同、两次 B 的结果是否相同),PARSING 阶段
// 本轮结果尚不可得。驻留模式仍在执行前检测(防第 3 轮副作用)。
if (step.toolCalls && step.toolCalls.length > 0) {
if (this.detectPingPong(step.toolCalls, step.toolResults)) {
log.warn(
`[AgentLoop] Ping-pong dead loop detected at iteration ${this.currentIteration} (ABAB with identical results — no progress)`,
);
this.emit('deadLoop', {
iteration: this.currentIteration,
runId: this.runId,
sessionId: this.currentSessionId,
});
throw new DeadLoopError(
`Detected a potential infinite loop: two alternating call patterns kept cycling (A→B→A→B) with identical results — no progress. Please refine the approach or provide more specific instructions.`,
);
}
}
// === OBSERVING ===
await this.transitionTo(AgentLoopState.OBSERVING);
@@ -1053,8 +1146,11 @@ export class AgentLoopEngine extends EventEmitter {
return true;
// P2-9 一致性修复: toLowerCase 避免大小写敏感漏判
// SSE 流中断 — 可重试(注意:用户主动 abort 已在 chatStreamWithRetry 入口由 this.aborted 提前拦截)
// v0.7.4 C-9 修复: 移除对 'aborted' 字样的宽泛匹配——用户主动 abort 路径已由
// this.aborted 拦截,错误 message 含 'aborted' 的多为 Provider 合法错误文案
// (如 Anthropic "request aborted" 变体),不应进入重试放大。仅保留网络层中断信号。
const msg = err.message?.toLowerCase() ?? '';
if (msg.includes('aborted') || msg.includes('socket hang up')) return true;
if (msg.includes('socket hang up') || msg.includes('fetch failed')) return true;
// 其他错误(400/401/403/4xx)不重试
return false;
}
@@ -1088,26 +1184,93 @@ export class AgentLoopEngine extends EventEmitter {
}
/**
* v0.3.0: 死循环检测
* v0.3.0: 死循环检测(驻留模式)—— v0.7.4 P4-1 拆分自 detectDeadLoop。
*
* 检测策略(v0.7.3 起双模式):
* 1. 驻留模式 — 将每轮的工具调用序列化为签名字符串,检查最近3轮的签名是否完全相同
* 如果连续3轮使用完全相同的参数调用相同的工具,判定为死循环。
* 2. 乒乓模式(v0.7.3 新增)— 最近4轮构成 ABAB 交替(r1===r3 && r2===r4 && r1!==r2)。
* 典型场景:模型在"读文件 A → 写文件 B"两步之间无限往返(每次读完又改回),
* 单步签名各不相同,驻留模式永不命中;docs/Agentic-Loop详解.md 第五章将
* "两种状态间反复来回切换、毫无进展"列为必须检测的停滞模式。
*
* v0.3.0 修复:
* - 对 args 的键进行排序,避免 JSON.stringify 键顺序不一致导致漏报
* 连续 3 轮调用签名完全相同(工具名 + 稳定序列化参数)→ 死循环。
* 在 PARSING 后、EXECUTING 前检测:无结果依赖,可安全地在副作用发生前终止
*
* @param toolCalls 当前轮次的工具调用
* @returns 是否检测到死循环
* @returns 是否检测到驻留死循环
*/
private detectDeadLoop(toolCalls: MetonaToolCall[]): boolean {
// 将当前轮次的工具调用序列化为签名
// v0.3.0 修复:使用 stable stringify,对对象键排序,确保相同内容不同键顺序产生相同签名
// v0.3.0 修复:添加 visited Set 防循环引用,深度上限防过度递归
private detectStuckLoop(toolCalls: MetonaToolCall[]): boolean {
const stableStringify = this.makeStableStringify();
const signature = toolCalls.map((tc) => `${tc.name}(${stableStringify(tc.args)})`).join('|');
this.toolCallHistory.push(signature);
// 本轮结果未知(执行前)——push 占位,乒乓检测在 EXECUTING 后补记真实结果
this.toolResultHistory.push('');
// 只保留最近5轮的记录(足够检测3轮重复与4轮乒乓,同时避免内存增长)
if (this.toolCallHistory.length > 5) {
this.toolCallHistory.shift();
this.toolResultHistory.shift();
}
const len = this.toolCallHistory.length;
if (len >= 3) {
const r1 = this.toolCallHistory[len - 1];
const r2 = this.toolCallHistory[len - 2];
const r3 = this.toolCallHistory[len - 3];
if (r1 === r2 && r2 === r3) return true;
}
return false;
}
/**
* v0.7.4 P4-1: 乒乓模式死循环检测(ABAB + 进度信号)。
*
* 最近 4 轮构成 ABAB 交替(r1===r3 && r2===r4 && r1!==r2)且**结果无推进**时
* 判定死循环。在 EXECUTING 之后调用(本轮工具结果已可用)。
*
* 进度信号:比对两次 A 轮的**工具结果签名**与两次 B 轮的结果签名——
* 若 A 两次结果相同、B 两次结果相同,说明模型在完全相同的操作间空转
* (外界状态未变,结果无新信息),判定死循环;任一结果有差异则放行
* (合法交替工作流如"读→写→读验证→写修复"结果随写入推进变化)。
* 结果签名缺失(工具失败/无成功结果)且四轮签名均为空时回退纯调用签名判定。
*
* @param toolCalls 当前轮次的工具调用(用于记录签名;驻留检测已 push 过调用签名)
* @param toolResults 当前轮次的工具执行结果(进度信号)
* @returns 是否检测到乒乓死循环
*/
private detectPingPong(toolCalls: MetonaToolCall[], toolResults?: MetonaToolResult[]): boolean {
// 补记本轮真实结果签名(覆盖 detectStuckLoop 的占位空串)
const stableStringify = this.makeStableStringify();
const resultSignature = (toolResults ?? [])
.filter((r) => r.success)
.map((r) => stableStringify(r.result))
.join('|');
if (this.toolResultHistory.length > 0) {
this.toolResultHistory[this.toolResultHistory.length - 1] = resultSignature;
}
const len = this.toolCallHistory.length;
if (len < 4) return false;
const a1 = this.toolCallHistory[len - 4];
const b1 = this.toolCallHistory[len - 3];
const a2 = this.toolCallHistory[len - 2];
const b2 = this.toolCallHistory[len - 1];
if (!(a1 === a2 && b1 === b2 && a1 !== b1)) return false;
// 进度信号:两次 A 的结果必须相同、两次 B 的结果必须相同,才判定空转死循环
const ra1 = this.toolResultHistory[len - 4];
const rb1 = this.toolResultHistory[len - 3];
const ra2 = this.toolResultHistory[len - 2];
const rb2 = this.toolResultHistory[len - 1];
// 四轮结果都可用且完全相同 → 空转死循环
if (ra1 !== '' && rb1 !== '' && ra2 !== '' && rb2 !== '' && ra1 === ra2 && rb1 === rb2) {
return true;
}
// 四轮结果签名全部缺失(工具失败/无成功结果)→ 回退纯调用签名判定
// (调用签名 ABAB 空转本身已构成停滞特征)
if (ra1 === '' && rb1 === '' && ra2 === '' && rb2 === '') {
return true;
}
return false;
}
/** 稳定序列化工厂(键排序 + 防循环引用 + 深度上限) */
private makeStableStringify(): (obj: unknown, visited?: Set<unknown>, depth?: number) => string {
const stableStringify = (
obj: unknown,
visited: Set<unknown> = new Set(),
@@ -1126,35 +1289,7 @@ export class AgentLoopEngine extends EventEmitter {
visited.delete(obj);
}
};
const signature = toolCalls.map((tc) => `${tc.name}(${stableStringify(tc.args)})`).join('|');
this.toolCallHistory.push(signature);
// 只保留最近5轮的记录(足够检测3轮重复与4轮乒乓,同时避免内存增长)
if (this.toolCallHistory.length > 5) {
this.toolCallHistory.shift();
}
const len = this.toolCallHistory.length;
// 模式 1:连续3轮完全相同 → 死循环
if (len >= 3) {
const r1 = this.toolCallHistory[len - 1]; // 当前轮
const r2 = this.toolCallHistory[len - 2]; // 上一轮
const r3 = this.toolCallHistory[len - 3]; // 上上一轮
if (r1 === r2 && r2 === r3) return true;
}
// 模式 2v0.7.3):最近4轮 ABAB 交替(A≠B)→ 乒乓死循环
if (len >= 4) {
const a1 = this.toolCallHistory[len - 4];
const b1 = this.toolCallHistory[len - 3];
const a2 = this.toolCallHistory[len - 2];
const b2 = this.toolCallHistory[len - 1];
if (a1 === a2 && b1 === b2 && a1 !== b1) return true;
}
return false;
return stableStringify;
}
/**