From 28c547c3098f4ff6ce42876a661d0573678e8712 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 22 Sep 2026 13:59:45 +0800 Subject: [PATCH 01/20] fork 0.5.1-fork.1: root-cause fixes from dist-patch era + --global single-daemon mode - zcode detectCompaction: reuse cached getConn (was new Database per session per cycle, never closed - handle leak + memory sawtooth root cause, upstream #2); healthCheck closes probe conn - cli/service windowsRunScript: quote nodeAbs (upstream #1) + chcp 65001 for CJK paths; vbs written UTF-16LE BOM (wscript misreads UTF-8 CJK paths as GBK) - capture/sync: per-session content-signature watermark (adapter.changeProbe: zcode MAX(rowid) aggregate, 2 SQL/cycle; file sources mtime+size) + known-in-db guard (rebuild/forget safe) + 10-min full sweep backstop -> idle daemon CPU 7.5% -> 0.08% - capture/watch: per-project named-pipe single-instance lock (upstream #2 instance accumulation); worker extraction; --global mode: one process adopts all registry projects dynamically --- package-lock.json | 12 +-- package.json | 2 +- src/adapters/types.ts | 8 ++ src/adapters/zcode/index.ts | 27 ++++++- src/bin/srelay.ts | 1 + src/capture/sync.ts | 29 +++++++ src/capture/watch.ts | 147 +++++++++++++++++++++++++++++++----- src/cli/service.ts | 31 +++++--- src/cli/watch.ts | 17 ++++- 9 files changed, 235 insertions(+), 39 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9d77b63..cf24e33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,13 @@ { - "name": "sessionrelay", - "version": "0.4.4", + "name": "@ewanjasper/sessionrelay", + "version": "0.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "sessionrelay", - "version": "0.4.4", + "name": "@ewanjasper/sessionrelay", + "version": "0.5.1", + "license": "MIT", "dependencies": { "@inquirer/prompts": "^8.7.0", "@modelcontextprotocol/sdk": "^1.30.0", @@ -30,7 +31,7 @@ "vitest": "^3.2.7" }, "engines": { - "node": ">=18" + "node": ">=22" } }, "node_modules/@esbuild/aix-ppc64": { @@ -1737,6 +1738,7 @@ "version": "13.0.3", "resolved": "https://registry.npmmirror.com/better-sqlite3/-/better-sqlite3-13.0.3.tgz", "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", + "hasInstallScript": true, "license": "MIT", "dependencies": { "node-addon-api": "^8.0.0" diff --git a/package.json b/package.json index 8bac673..82d7307 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ewanjasper/sessionrelay", - "version": "0.5.1", + "version": "0.5.1-fork.1", "description": "会话接力 SessionRelay — 属于项目、不属于任何厂商的本地记忆层(跨 Agent 会话记忆 / 中文检索 / MCP / HOP 交接协议)", "license": "MIT", "type": "module", diff --git a/src/adapters/types.ts b/src/adapters/types.ts index eb6920e..e32a5bf 100644 --- a/src/adapters/types.ts +++ b/src/adapters/types.ts @@ -46,6 +46,14 @@ export interface SessionSourceAdapter { * 返回该会话的 compaction 信息;无压缩返回 null */ detectCompaction?(ds: DiscoveredSession, config: AdapterConfig): CompactionInfo | null; + + /** + * [fork 0922] 内容变更探针:一次调用返回"会话ID → 内容签名"全表快照。 + * 供 runSync 水位线跳过未变更会话——每周期总共一次聚合查询,而非每会话逐条探测。 + * 签名必须由内容决定(如 MAX(rowid)),不得用 mtime:内容变更未必 bump mtime(上游测试契约)。 + * 未实现时 runSync 退回 mtimeMs+sizeBytes 签名(对文件型源已足够)。 + */ + changeProbe?(config: AdapterConfig): Map; } /** compaction 检测结果 */ diff --git a/src/adapters/zcode/index.ts b/src/adapters/zcode/index.ts index 83b7b61..4cd71da 100644 --- a/src/adapters/zcode/index.ts +++ b/src/adapters/zcode/index.ts @@ -153,7 +153,9 @@ export function readNew(ds: DiscoveredSession, dbPath: string, cursor: unknown): // ── 改动 3:compaction 检测 ── export function detectCompaction(ds: DiscoveredSession, dbPath: string): CompactionInfo | null { if (!fs.existsSync(dbPath)) return null; - const z = new Database(dbPath, { readonly: true }); + // [fork] 复用 getConn 缓存连接——原实现每个会话每周期 new Database 却从不关闭 + // (注释"缓存连接"系自 getConn 误复制),连接全靠 GC 兜底 → 句柄泄漏 + 内存锯齿 + CPU 空烧(上游 #2 根因) + const z = getConn(dbPath); try { z.pragma('busy_timeout = 3000'); const comp = z.prepare(` @@ -174,7 +176,7 @@ export function detectCompaction(ds: DiscoveredSession, dbPath: string): Compact summaryMessageId: data.summaryMessageId, }; } finally { - // 缓存连接,不关闭 + // 复用缓存连接,不关闭(与注释语义一致) } } @@ -197,7 +199,7 @@ export const adapter: SessionSourceAdapter = { if (!fs.existsSync(dbPath)) return `数据库不存在:${dbPath}(未安装 ZCode 可忽略)`; try { const z = new Database(dbPath, { readonly: true }); - // 缓存连接,不关闭 + z.close(); // [fork] 探测完即关:一次性连接不留着等 GC(原实现泄漏句柄,doctor 路径) return null; } catch (e) { return `只读探测失败:${(e as Error).message}`; @@ -206,4 +208,23 @@ export const adapter: SessionSourceAdapter = { detectCompaction(ds, config) { return detectCompaction(ds, config.dbPath as string); }, + + // [fork 0922] 内容签名:两条聚合查询拿全表 MAX(rowid)(消息+part,part 覆盖 compaction 追加)。 + // 内容变更必动 rowid;mtime 不可靠(测试夹具证明追加可以不 bump time_updated)。 + changeProbe(config) { + const dbPath = config.dbPath as string; + const out = new Map(); + if (!fs.existsSync(dbPath)) return out; + const z = getConn(dbPath); + try { + z.pragma('busy_timeout = 3000'); + const msg = z.prepare('SELECT session_id, MAX(rowid) AS m FROM message GROUP BY session_id').all() as Array<{ session_id: string; m: number }>; + const part = z.prepare('SELECT session_id, MAX(rowid) AS m FROM part GROUP BY session_id').all() as Array<{ session_id: string; m: number }>; + for (const r of msg) out.set(r.session_id, String(r.m)); + for (const r of part) out.set(r.session_id, `${out.get(r.session_id) ?? '0'}/${r.m}`); + return out; + } catch { + return out; // 探针失败 → 空签名 → 全部不命中水位线 → 全量 ingest(安全侧) + } + }, }; diff --git a/src/bin/srelay.ts b/src/bin/srelay.ts index d5fbbb0..5158909 100644 --- a/src/bin/srelay.ts +++ b/src/bin/srelay.ts @@ -30,6 +30,7 @@ program .command('watch') .description('守护捕获(前台运行;--install-service 注册系统服务)') .option('--foreground', '前台运行(服务调用路径)') + .option('--global', '全局守护:一个进程看管项目注册表里的全部项目(动态收编新项目)') .option('--install-service', '注册守护服务(Windows 计划任务)') .option('--uninstall', '卸载守护服务') .option('--status', '查看守护与服务状态') diff --git a/src/capture/sync.ts b/src/capture/sync.ts index 49e6074..dc95949 100644 --- a/src/capture/sync.ts +++ b/src/capture/sync.ts @@ -35,6 +35,17 @@ function titleFromMessages(msgs: Array<{ role: string; content: string }>): stri return first.content.replace(/\s+/g, ' ').trim().slice(0, 60); } +// [fork 0922] 会话水位线:内容签名未变 且 会话仍在库里 → 整跳过(discover 仍跑,ingestOne 清零)。 +// 背景:N 个项目守护各自监听同一批源目录,源库任何写入都触发所有守护各跑一轮 cycle, +// 每个 cycle 又对每个已知会话跑 3-4 条查询——活跃时段单守护 7.5% 单核,守护越多放大越狠。 +// 签名铁律:内容由 adapter.changeProbe(zcode=MAX(rowid) 聚合,每周期 2 条 SQL)决定; +// 文件型源退回 mtimeMs+sizeBytes。不得只用 mtime——内容变更未必 bump mtime(上游测试契约)。 +// "仍在库里"缺一不可(上游测试抓出的真缺陷):rebuild/forget/手动删行后库与签名脱钩, +// 没有它重建后的库永远收不到重摄。已知会话集=每周期一条本地聚合查询,代价毫秒级。 +// 内存态即够:守护重启后首轮全量(本就是补账需求);10 分钟一次全量清扫兜底一切"以为没变其实变了"。 +const syncWatermark = new Map(); +let lastFullSweep = 0; + export async function runSync(opts: SyncOptions): Promise { const root = opts.projectRoot; const cfg = opts.config; @@ -43,6 +54,10 @@ export async function runSync(opts: SyncOptions): Promise { if (mode === 'off') return result; + // [fork 0922] 10 分钟全量清扫:清水位线跑一轮完整 ingest,堵一切"以为没变其实变了"的边角 + if (Date.now() - lastFullSweep > 600_000) syncWatermark.clear(); + lastFullSweep = Date.now(); + // 改动 1:注册表初始化(含 custom adapter 加载) ensureRegistered(root); const customResult = ensureRegistered(root); @@ -51,6 +66,10 @@ export async function runSync(opts: SyncOptions): Promise { const own = !opts.db; const db = opts.db ?? createDb(dbFile(root)); const projectId = cfg.identity.project_id ?? projectIdOf(root); + // [fork 0922] 已知会话集:库里现存 (source, source_session_id)——水位线跳过的第二个必要条件 + const knownSessions = new Set( + (db.prepare("SELECT source || ':' || source_session_id AS k FROM sessions WHERE project_id = ?").all(projectId) as Array<{ k: string }>).map(r => r.k) + ); const ignoreRules = loadIgnoreRules(root); // forget 防复活次级防线(设计 v4 §3.2):入口整表载入一次,ingest 内 Set 判定 const tombstones = loadTombstones(db); @@ -68,6 +87,8 @@ export async function runSync(opts: SyncOptions): Promise { } const aConfig = adapterConfig(cfg, source); const discovered = adapter.discover(root, aConfig); + // [fork 0922] 内容签名探针:每源每周期一次聚合查询;无探针的源退回 mtime+size + const probe = adapter.changeProbe?.(aConfig); for (const ds of discovered) { result.discovered++; @@ -86,7 +107,15 @@ export async function runSync(opts: SyncOptions): Promise { continue; } + // [fork 0922] 水位线:内容签名未变 且 会话仍在库里 → 跳过(rebuild/forget/删行后自动失效) + const wmKey = `${ds.source}:${ds.sourceSessionId}`; + const sig = probe + ? probe.get(ds.sourceSessionId) + : (ds.mtimeMs !== undefined ? `${ds.mtimeMs}:${ds.sizeBytes}` : undefined); + if (sig !== undefined && syncWatermark.get(wmKey) === sig && knownSessions.has(wmKey)) continue; + await ingestOne(db, ds, { mode, projectId, cfg, result, stats: opts.stats, ignoreRules, tombstones, source, aConfig }); + if (sig !== undefined) syncWatermark.set(wmKey, sig); } } } finally { diff --git a/src/capture/watch.ts b/src/capture/watch.ts index b641766..e588b1f 100644 --- a/src/capture/watch.ts +++ b/src/capture/watch.ts @@ -1,7 +1,16 @@ // watch 守护(技术方案 §5.5 / 改进方案 改动1 注册表化) +// [fork 0922] 三项强化: +// ① 每项目命名管道单实例锁——上游 #2 实例堆积根治(原 acquireLock 先查后写非原子, +// 并发 spawn 同秒全过;管道是内核对象随进程死亡自动消失,EADDRINUSE 即活实例) +// ② runWatch 拆出 startWatchWorker(可停的 worker),为 --global 复用 +// ③ --global 全局守护:一个进程看管项目注册表里的全部项目,sweep 动态收编/移除 import path from 'node:path'; +import fs from 'node:fs'; +import net from 'node:net'; +import crypto from 'node:crypto'; import { openExisting } from '../store/db.js'; import type { RelayConfig } from '../shared/config.js'; +import { loadConfig } from '../shared/config.js'; import { projectIdOf, dbFile } from '../shared/paths.js'; import { acquireLock, touchLock, releaseLock, isDaemonAlive } from '../shared/lock.js'; import { watchDir } from '../adapters/claude-code/watcher.js'; @@ -9,6 +18,7 @@ import { runSync } from './sync.js'; import { runJudge } from './judge.js'; import { consumeHookEvents } from './hook-spool.js'; import { ensureRegistered, get, adapterConfig } from '../adapters/registry.js'; +import { registryCandidates } from '../shared/registry.js'; export interface WatchOptions { projectRoot: string; @@ -16,15 +26,59 @@ export interface WatchOptions { log?: (msg: string) => void; } -export async function runWatch(opts: WatchOptions): Promise { +// ── [fork 0922] 每项目命名管道单实例锁 ── +const pipeLockServers = new Map(); + +function pipeLockName(root: string): string { + // 反斜杠经 String.fromCharCode 构造,杜绝转义层吃反斜杠;小写归一 + sha1 前 16 位 + const BS = String.fromCharCode(92); + const h = crypto.createHash('sha1').update(path.resolve(root).toLowerCase()).digest('hex').slice(0, 16); + return BS + BS + '.' + BS + 'pipe' + BS + 'srelay-watch-' + h; +} + +function acquirePipeLock(root: string, log: (m: string) => void): Promise { + return new Promise((resolve) => { + if (process.platform !== 'win32') { resolve(true); return; } // 非 Windows 走原文件锁 + const srv = net.createServer(); + srv.once('error', (e: NodeJS.ErrnoException) => { + if (e && e.code === 'EADDRINUSE') { + log(`管道锁被占(另一守护在跑),本实例退出: ${pipeLockName(root)}`); + resolve(false); + } else resolve(true); // 管道不可用 → 降级回文件锁语义 + }); + srv.listen(pipeLockName(root), () => { + pipeLockServers.set(path.resolve(root).toLowerCase(), srv); + resolve(true); + }); + }); +} + +function releasePipeLock(root: string): void { + const key = path.resolve(root).toLowerCase(); + const srv = pipeLockServers.get(key); + if (srv) { + try { srv.close(); } catch { /* 进程退出内核同样回收 */ } + pipeLockServers.delete(key); + } +} + +// ── [fork 0922] 可停 worker:单项目守护的全部生命周期(原 runWatch 主体,信号等待外提) ── +export interface WatchWorker { + root: string; + stop(): Promise; +} + +export async function startWatchWorker(opts: WatchOptions): Promise { const root = opts.projectRoot; const log = opts.log ?? ((m: string) => process.stderr.write(`[srelay-watch] ${m}\n`)); + if (!(await acquirePipeLock(root, log))) return null; // [fork 0922] 同项目已有守护在跑 + const lock = acquireLock(root); if (!lock.ok) { const alive = isDaemonAlive(root); log(lock.reason + (alive.alive ? '' : '(锁已僵死,本次接管)')); - if (alive.alive) return; + if (alive.alive) { releasePipeLock(root); return null; } } const heartbeat = setInterval(() => touchLock(root), 15_000); @@ -54,6 +108,7 @@ export async function runWatch(opts: WatchOptions): Promise { const watchers: Array<{ close(): void }> = []; const judgeTimer = setInterval(() => cycle('tick'), 30_000); const safetyTimer = setInterval(() => cycle('safety'), 60_000); + let stopped = false; try { await cycle('initial'); @@ -74,20 +129,78 @@ export async function runWatch(opts: WatchOptions): Promise { log(`监听 ${dir}`); } catch { /* 目录不存在,安全定时器兜底 */ } } - log(`守护运行中(pid ${process.pid},Ctrl+C 退出)`); - await new Promise((resolve) => { - const stop = () => resolve(); - process.once('SIGINT', stop); - process.once('SIGTERM', stop); - }); - } finally { - for (const w of watchers) w.close(); - clearInterval(heartbeat); - clearInterval(judgeTimer); - clearInterval(safetyTimer); - await chain.catch(() => {}); - db.close(); - releaseLock(root); - log('已退出'); + log(`守护运行中(pid ${process.pid},项目 ${root})`); + } catch (e) { + // [fork 0922] worker 初始化失败:释放已持资源并报告 null(全局模式下跳过该项目,不拖垮进程) + clearInterval(heartbeat); clearInterval(judgeTimer); clearInterval(safetyTimer); + releasePipeLock(root); + log(`守护启动失败(${root}): ${(e as Error).message}`); + return null; } + + return { + root, + stop: async () => { + if (stopped) return; + stopped = true; + for (const w of watchers) w.close(); + clearInterval(heartbeat); clearInterval(judgeTimer); clearInterval(safetyTimer); + await chain.catch(() => {}); + db.close(); + releaseLock(root); + releasePipeLock(root); + log('已退出'); + }, + }; +} + +/** 单项目守护(原入口语义):worker + SIGINT/SIGTERM 等待 */ +export async function runWatch(opts: WatchOptions): Promise { + const worker = await startWatchWorker(opts); + if (!worker) return; + await new Promise((resolve) => { + const stop = () => resolve(); + process.once('SIGINT', stop); + process.once('SIGTERM', stop); + }); + await worker.stop(); +} + +// ── [fork 0922] 全局守护:一个进程看管注册表全部项目 ── +export async function runWatchGlobal(opts: { log?: (msg: string) => void } = {}): Promise { + const log = opts.log ?? ((m: string) => process.stderr.write(`[srelay-watch] ${m}\n`)); + const workers = new Map(); + + const adopt = async (why: string) => { + for (const c of registryCandidates()) { + const key = path.resolve(c.root).toLowerCase(); + if (workers.has(key)) continue; + try { + const w = await startWatchWorker({ projectRoot: c.root, config: loadConfig(c.root), log: (m) => log(`[${c.name}] ${m}`) }); + if (w) { workers.set(key, w); log(`${why}: 收编 ${c.root}`); } + } catch (e) { + log(`${why}: 收编失败 ${c.root}: ${(e as Error).message}`); + } + } + // 根目录消失的项目:停 worker 并移除 + for (const [key, w] of workers) { + if (!fs.existsSync(w.root)) { + await w.stop(); + workers.delete(key); + log(`${why}: 移除失效项目 ${w.root}`); + } + } + }; + + await adopt('initial'); + const adoptTimer = setInterval(() => { void adopt('sweep'); }, 10 * 60_000); // 与 runSync 全量清扫同频:新项目最迟 10 分钟被收编 + log(`全局守护运行中(pid ${process.pid},项目 ${workers.size},Ctrl+C 退出)`); + await new Promise((resolve) => { + const stop = () => resolve(); + process.once('SIGINT', stop); + process.once('SIGTERM', stop); + }); + clearInterval(adoptTimer); + for (const w of workers.values()) await w.stop(); + log('已退出'); } diff --git a/src/cli/service.ts b/src/cli/service.ts index 9ff60f9..86bd41d 100644 --- a/src/cli/service.ts +++ b/src/cli/service.ts @@ -46,15 +46,16 @@ export function resolveWatchEntryFrom(moduleUrl: string): { entry: string; exist return { entry, exists: fs.existsSync(entry) }; } -/** 守护启动命令参数(dev=tsx loader / prod=dist/srelay.js 稳定入口) */ -export function buildWatchArgs(root: string): string[] { +/** 守护启动命令参数(dev=tsx loader / prod=dist/srelay.js 稳定入口;global=全局单守护模式) */ +export function buildWatchArgs(root: string, opts?: { global?: boolean }): string[] { const isDev = import.meta.url.endsWith('.ts'); + const extra = opts?.global ? ['--global'] : []; if (isDev) { const loader = path.join(repoRoot(), 'node_modules', 'tsx', 'dist', 'loader.mjs'); const { entry } = resolveWatchEntry(); - return ['--import', pathToFileURLSafe(loader), entry, 'watch', '--foreground']; + return ['--import', pathToFileURLSafe(loader), entry, 'watch', '--foreground', ...extra]; } - return [resolveWatchEntry().entry, 'watch', '--foreground']; + return [resolveWatchEntry().entry, 'watch', '--foreground', ...extra]; } function pathToFileURLSafe(p: string): string { @@ -144,12 +145,16 @@ WantedBy=default.target // ── 安装/卸载/状态(分平台执行) ── -function windowsRunScript(root: string): string { +function windowsRunScript(root: string, opts?: { global?: boolean }): string { const nodeAbs = process.execPath; - const args = buildWatchArgs(root).map((a) => (a.includes(' ') ? `"${a}"` : a)).join(' '); + const args = buildWatchArgs(root, opts).map((a) => (a.includes(' ') ? `"${a}"` : a)).join(' '); const logFile = watchLogPath(root); // 输出全部落盘:静默启动后报错必须可诊断(用户教训:闪框有信号,纯静默=故障不可见) - return ['@echo off', `cd /d "${root}"`, `${nodeAbs} ${args} >> "${logFile}" 2>&1`, ''].join('\r\n'); + // [fork] ①nodeAbs 加引号(默认装在 C:\Program Files\nodejs,含空格裸奔必死于 'C:\Program',上游 #1); + // ②首行 chcp 65001:cmd.exe 按 OEM 码页(中文系统=GBK)逐行解析本文件, + // 而 fs.writeFileSync 落盘是 UTF-8——项目路径含中文时 cd/重定向全部乱码; + // chcp 行本身纯 ASCII 任何码页下都先被正确解析,之后各行按 UTF-8 解析,与文件编码对齐 + return ['@echo off', 'chcp 65001 >nul', `cd /d "${root}"`, `"${nodeAbs}" ${args} >> "${logFile}" 2>&1`, ''].join('\r\n'); } /** @@ -178,7 +183,7 @@ export function windowsSilentVbs(cmdPath: string): string { return `CreateObject("Wscript.Shell").Run """${cmdPath}""", 0, False\r\n`; } -export async function installWatchService(root: string): Promise { +export async function installWatchService(root: string, opts?: { global?: boolean }): Promise { fs.mkdirSync(relayDir(root), { recursive: true }); // 入口预检(chunk-hash 事故防线):守护入口必须是稳定存在文件—— // 0.4.0 前这里写入带 hash 的 chunk 路径,dist 重建后开机即 MODULE_NOT_FOUND @@ -191,9 +196,15 @@ export async function installWatchService(root: string): Promise { if (process.platform === 'win32') { const { REG_PATH, REG_NAME } = await import('./winregistry.js'); const cmdPath = path.join(relayDir(root), 'watch-task.cmd'); - fs.writeFileSync(cmdPath, windowsRunScript(root), 'utf8'); + fs.writeFileSync(cmdPath, windowsRunScript(root, opts), 'utf8'); const vbsPath = path.join(relayDir(root), 'watch-task.vbs'); - fs.writeFileSync(vbsPath, windowsSilentVbs(cmdPath), 'utf8'); + // [fork] vbs 必须 UTF-16LE+BOM:wscript 只认 ANSI/UTF-16, + // UTF-8 落盘的中文路径会被按 GBK 误读(cmd 之下更深一层的同款编码坑) + fs.writeFileSync(vbsPath, Buffer.concat([ + Buffer.from([0xff, 0xfe]), + Buffer.from(windowsSilentVbs(cmdPath), 'utf16le'), + ])); + if (opts?.global) console.log(pc.dim(' 模式:全局守护(--global,一个进程看管注册表全部项目)')); try { await execFileP('powershell', ['-Command', `Set-ItemProperty -Path '${REG_PATH}' -Name '${REG_NAME}' -Value 'wscript.exe "${vbsPath}"'`]); diff --git a/src/cli/watch.ts b/src/cli/watch.ts index 73a99be..37d71a5 100644 --- a/src/cli/watch.ts +++ b/src/cli/watch.ts @@ -2,13 +2,13 @@ import fs from 'node:fs'; import { loadConfig } from '../shared/config.js'; import { isDaemonAlive } from '../shared/lock.js'; -import { runWatch } from '../capture/watch.js'; +import { runWatch, runWatchGlobal } from '../capture/watch.js'; import { findRelayRoot } from '../shared/paths.js'; import { touchRegistry } from '../shared/registry.js'; import { pc } from './ui.js'; import { installWatchService, uninstallWatchService, watchServiceStatus, watchLogPath, rotateWatchLog, readLogTail } from './service.js'; -export async function cmdWatch(opts: { foreground?: boolean; installService?: boolean; uninstall?: boolean; status?: boolean }): Promise { +export async function cmdWatch(opts: { foreground?: boolean; global?: boolean; installService?: boolean; uninstall?: boolean; status?: boolean }): Promise { // watch 默认前台运行(服务与手动皆同路径) const root = process.cwd(); if (opts.uninstall) return uninstallWatchService(root); @@ -26,7 +26,18 @@ export async function cmdWatch(opts: { foreground?: boolean; installService?: bo } return; } - if (opts.installService) return installWatchService(root); + if (opts.installService) return installWatchService(root, { global: opts.global }); + // [fork 0922] 全局守护:一个进程看管项目注册表里的全部项目(无需 cwd 在某项目内) + if (opts.global) { + const rr = findRelayRoot(root); + if (rr) { + rotateWatchLog(rr); // 启动时轮转 + const rotator = setInterval(() => rotateWatchLog(rr), 3_600_000); + rotator.unref(); + } + await runWatchGlobal(); + return; + } // 前台守护:要求已初始化 const rr = findRelayRoot(root); if (!rr) { From 7db7ae28a105e1a21552ccba80d16780e4fb81f8 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 22 Sep 2026 14:06:04 +0800 Subject: [PATCH 02/20] fork: global-instance pipe lock (fixed name, cwd-independent) - second --global process was adopting 0 projects and idling as zombie --- src/capture/watch.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/capture/watch.ts b/src/capture/watch.ts index e588b1f..1ef9989 100644 --- a/src/capture/watch.ts +++ b/src/capture/watch.ts @@ -169,6 +169,23 @@ export async function runWatch(opts: WatchOptions): Promise { // ── [fork 0922] 全局守护:一个进程看管注册表全部项目 ── export async function runWatchGlobal(opts: { log?: (msg: string) => void } = {}): Promise { const log = opts.log ?? ((m: string) => process.stderr.write(`[srelay-watch] ${m}\n`)); + // [fork] 全局实例锁:项目级管道锁只挡 worker,不挡壳——没有它,第二个 --global + // 进程会收编 0 个项目后空转变僵尸。固定名(与 cwd 无关),内核对象随进程死亡自动消失。 + if (process.platform === 'win32') { + const BS = String.fromCharCode(92); + const glockAlive = await new Promise((resolve) => { + const srv = net.createServer(); + srv.once('error', (e: NodeJS.ErrnoException) => { + if (e && e.code === 'EADDRINUSE') { + log('全局守护已在运行(管道锁被占),本实例退出'); + resolve(false); + } else resolve(true); + }); + srv.listen(BS + BS + '.' + BS + 'pipe' + BS + 'srelay-watch-global', () => resolve(true)); + }); + if (!glockAlive) return; + } + const workers = new Map(); const adopt = async (why: string) => { From 3da65c3c0081380954f5244045135b2e1dd60de2 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 22 Sep 2026 14:16:12 +0800 Subject: [PATCH 03/20] fork: sweep stops workers whose project config.json is gone - deleted projects were unreleasable (sqlite/cmd held open by this process), now auto-pruned within one sweep --- src/capture/watch.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/capture/watch.ts b/src/capture/watch.ts index 1ef9989..697b1b5 100644 --- a/src/capture/watch.ts +++ b/src/capture/watch.ts @@ -200,8 +200,10 @@ export async function runWatchGlobal(opts: { log?: (msg: string) => void } = {}) } } // 根目录消失的项目:停 worker 并移除 + // [fork] 项目删除时 sqlite/.cmd 被本进程句柄锁住、目录删不干净,只查根目录永远不触发; + // config.json(loadConfig 读完即关,不持句柄)消失 = 用户已删库跑路的可靠信号 for (const [key, w] of workers) { - if (!fs.existsSync(w.root)) { + if (!fs.existsSync(w.root) || !fs.existsSync(path.join(w.root, '.sessionrelay', 'config.json'))) { await w.stop(); workers.delete(key); log(`${why}: 移除失效项目 ${w.root}`); From 56d54d0b994e9bafc36a78f9404631fda64eebde Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 22 Sep 2026 14:20:23 +0800 Subject: [PATCH 04/20] fork: unify pipe-lock helpers (global+project share one code path, global lock released on exit); fix ensureRegistered double-call that swallowed custom-adapter load errors (upstream wart) --- src/capture/sync.ts | 4 ++-- src/capture/watch.ts | 48 +++++++++++++++++++++++++------------------- 2 files changed, 29 insertions(+), 23 deletions(-) diff --git a/src/capture/sync.ts b/src/capture/sync.ts index dc95949..1ab7b4a 100644 --- a/src/capture/sync.ts +++ b/src/capture/sync.ts @@ -58,8 +58,8 @@ export async function runSync(opts: SyncOptions): Promise { if (Date.now() - lastFullSweep > 600_000) syncWatermark.clear(); lastFullSweep = Date.now(); - // 改动 1:注册表初始化(含 custom adapter 加载) - ensureRegistered(root); + // 改动 1:注册表初始化(含 custom adapter 加载)——[fork] 原 here 连调两次, + // 第二次因 customLoaded 守卫恒返回空结果,custom adapter 的加载错误从未上报 const customResult = ensureRegistered(root); for (const err of customResult.errors) result.warnings.push(`custom adapter 加载失败:${err}`); diff --git a/src/capture/watch.ts b/src/capture/watch.ts index 697b1b5..d65c4bf 100644 --- a/src/capture/watch.ts +++ b/src/capture/watch.ts @@ -36,29 +36,47 @@ function pipeLockName(root: string): string { return BS + BS + '.' + BS + 'pipe' + BS + 'srelay-watch-' + h; } -function acquirePipeLock(root: string, log: (m: string) => void): Promise { +function globalPipeName(): string { + const BS = String.fromCharCode(92); + return BS + BS + '.' + BS + 'pipe' + BS + 'srelay-watch-global'; +} + +function acquirePipeLockByName(name: string, log: (m: string) => void, label: string): Promise { return new Promise((resolve) => { if (process.platform !== 'win32') { resolve(true); return; } // 非 Windows 走原文件锁 const srv = net.createServer(); srv.once('error', (e: NodeJS.ErrnoException) => { if (e && e.code === 'EADDRINUSE') { - log(`管道锁被占(另一守护在跑),本实例退出: ${pipeLockName(root)}`); + log(`${label}锁被占(另一守护在跑),本实例退出: ${name}`); resolve(false); } else resolve(true); // 管道不可用 → 降级回文件锁语义 }); - srv.listen(pipeLockName(root), () => { - pipeLockServers.set(path.resolve(root).toLowerCase(), srv); + srv.listen(name, () => { + pipeLockServers.set(name, srv); resolve(true); }); }); } +function acquirePipeLock(root: string, log: (m: string) => void): Promise { + return acquirePipeLockByName(pipeLockName(root), log, '管道'); +} + function releasePipeLock(root: string): void { - const key = path.resolve(root).toLowerCase(); - const srv = pipeLockServers.get(key); + const name = pipeLockName(root); + const srv = pipeLockServers.get(name); if (srv) { try { srv.close(); } catch { /* 进程退出内核同样回收 */ } - pipeLockServers.delete(key); + pipeLockServers.delete(name); + } +} + +function releaseGlobalPipeLock(): void { + const name = globalPipeName(); + const srv = pipeLockServers.get(name); + if (srv) { + try { srv.close(); } catch { /* 忽略 */ } + pipeLockServers.delete(name); } } @@ -171,20 +189,7 @@ export async function runWatchGlobal(opts: { log?: (msg: string) => void } = {}) const log = opts.log ?? ((m: string) => process.stderr.write(`[srelay-watch] ${m}\n`)); // [fork] 全局实例锁:项目级管道锁只挡 worker,不挡壳——没有它,第二个 --global // 进程会收编 0 个项目后空转变僵尸。固定名(与 cwd 无关),内核对象随进程死亡自动消失。 - if (process.platform === 'win32') { - const BS = String.fromCharCode(92); - const glockAlive = await new Promise((resolve) => { - const srv = net.createServer(); - srv.once('error', (e: NodeJS.ErrnoException) => { - if (e && e.code === 'EADDRINUSE') { - log('全局守护已在运行(管道锁被占),本实例退出'); - resolve(false); - } else resolve(true); - }); - srv.listen(BS + BS + '.' + BS + 'pipe' + BS + 'srelay-watch-global', () => resolve(true)); - }); - if (!glockAlive) return; - } + if (!(await acquirePipeLockByName(globalPipeName(), log, '全局'))) return; const workers = new Map(); @@ -221,5 +226,6 @@ export async function runWatchGlobal(opts: { log?: (msg: string) => void } = {}) }); clearInterval(adoptTimer); for (const w of workers.values()) await w.stop(); + releaseGlobalPipeLock(); log('已退出'); } From cd0f3ab5da8b9a1d8ba1c5261d5efc163ee775c5 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 22 Sep 2026 14:30:08 +0800 Subject: [PATCH 05/20] =?UTF-8?q?fork:=20decisions=20--limit=20takes=20the?= =?UTF-8?q?=20NEWEST=20N=20(was=20oldest-N=20shown=20as=20'=E6=9C=80?= =?UTF-8?q?=E8=BF=91=E5=86=B3=E7=AD=96');=20upstream=20wart=20exposed=20by?= =?UTF-8?q?=20brief=20quality=20audit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cli/meta.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/cli/meta.ts b/src/cli/meta.ts index 461ce32..f7167f7 100644 --- a/src/cli/meta.ts +++ b/src/cli/meta.ts @@ -10,8 +10,10 @@ export async function cmdDecisions(opts: { topic?: string; source?: string; limi const cfg = loadConfig(root); const db = openRelayDb(root); try { - const rows = listDecisions(db, cfg.identity.project_id ?? root, { topic: opts.topic, source: opts.source }) - .slice(0, opts.limit ?? 20); + // [fork] listDecisions 升序返回(全列表"按时间"语义);但 limit 语义应取"最近 N 条"—— + // 原实现 slice(0, limit) 取的是最老的 N 条,简报/CLI 的"最近决策"永远显示陈年旧账 + const all = listDecisions(db, cfg.identity.project_id ?? root, { topic: opts.topic, source: opts.source }); + const rows = all.slice(-(opts.limit ?? 20)).reverse(); if (opts.json) { console.log(JSON.stringify({ count: rows.length, decisions: rows }, null, 2)); return; } if (rows.length === 0) { console.log(pc.dim('(暂无已确认决策。会话 confirmed 后自动提取——见 srelay list --state pending)')); From 2f893ef2d741348c17e2ec639a67368ba621cb0a Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 22 Sep 2026 14:35:15 +0800 Subject: [PATCH 06/20] fork: decision plausibility guard at extraction (reject table rows / markdown fragments / embedded JSON residue) + unresolved 14-day fade in CLI --- src/cli/meta.ts | 5 ++++- src/core/extract/extract.ts | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/cli/meta.ts b/src/cli/meta.ts index f7167f7..d7879d5 100644 --- a/src/cli/meta.ts +++ b/src/cli/meta.ts @@ -34,7 +34,10 @@ export async function cmdUnresolved(opts: { limit?: number; json?: boolean }): P const cfg = loadConfig(root); const db = openRelayDb(root); try { - const rows = listUnresolved(db, cfg.identity.project_id ?? root, opts.limit ?? 20); + const cutoff = Date.now() - 14 * 86_400_000; // [fork 0922] 未解决有保质期:14 天自动淡出(老问题没被再问起就该沉底) + const rows = listUnresolved(db, cfg.identity.project_id ?? root, 100) + .filter((r) => new Date(r.at).getTime() >= cutoff) + .slice(0, opts.limit ?? 20); if (opts.json) { console.log(JSON.stringify({ count: rows.length, unresolved: rows }, null, 2)); return; } if (rows.length === 0) { console.log(pc.dim('(暂无未决问题)')); return; } for (const r of rows) { diff --git a/src/core/extract/extract.ts b/src/core/extract/extract.ts index d5e7319..0b4ad28 100644 --- a/src/core/extract/extract.ts +++ b/src/core/extract/extract.ts @@ -69,6 +69,19 @@ function sentences(text: string): string[] { return text.split(/(?<=[。!?!?\n])/).map((s) => s.trim()).filter(Boolean); } +// [fork 0922] 决策合理性守卫:正则只管"句首有触发词",不管句子长什么样—— +// 实测垃圾三来源:看板表格行(| 分隔)、Markdown 粗体残片(**)、消息内嵌 JSON 转义串。 +// 在抽取端拒收,比事后清洗数据治本。 +function plausibleDecision(text: string): boolean { + const t = text.trim(); + if (t.length < 6) return false; // 纯碎片("决定装哪些:") + if (/[::|]\s*$/.test(t)) return false; // 半句:以冒号/竖线收尾 + if (/\*\*\s*$/.test(t) || /^\*\*/.test(t)) return false; // Markdown 粗体残片 + if ((t.match(/\|/g) ?? []).length >= 2) return false; // 表格行 + if (/"(?:confirmed|title|session_id)"\s*:/.test(t) || /\}\s*,\s*\{/.test(t)) return false; // 内嵌 JSON 残片 + return true; +} + export function extractDecisions(msgs: Msg[]): ExtractedMeta['decisions'] { const out: ExtractedMeta['decisions'] = []; const seen = new Set(); @@ -78,6 +91,7 @@ export function extractDecisions(msgs: Msg[]): ExtractedMeta['decisions'] { const hit = s.match(re); if (!hit) continue; const text = hit[0].replace(/\s+/g, ' ').trim().slice(0, 90); + if (!plausibleDecision(text)) break; // [fork 0922] 残片拒收 const key = text.slice(0, 20); if (seen.has(key)) break; seen.add(key); From 9683952b36b6a9a9c057aea738e0c7dcfd83fea6 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 22 Sep 2026 14:40:18 +0800 Subject: [PATCH 07/20] fork: unresolved --json actually emits JSON now (registration dropped opts.json - upstream wart the brief's text-fallback was papering over) --- src/bin/srelay.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/bin/srelay.ts b/src/bin/srelay.ts index 5158909..e14d6cc 100644 --- a/src/bin/srelay.ts +++ b/src/bin/srelay.ts @@ -221,7 +221,9 @@ program .option('--json') .action(async (opts) => { const { cmdUnresolved } = await import('../cli/meta.js'); - await cmdUnresolved({ limit: opts.limit ? Number(opts.limit) : undefined }); + // [fork 0922] 原实现丢弃 opts.json(选项存在却没传)——unresolved --json 永远输出人话, + // 依赖 JSON 的调用方(开场简报)被迫走文本兜底、丧失一切结构化过滤 + await cmdUnresolved({ limit: opts.limit ? Number(opts.limit) : undefined, json: opts.json }); }); program From c63b5daec1ee17a5b7c5ae96cb4bfc5e73967aaf Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 22 Sep 2026 15:06:48 +0800 Subject: [PATCH 08/20] fork (audit round): 5 fixes from pattern-scan subagent - mcp adopt(): close previous project's db on switch (handle leak + EBUSY vs forget/rebuild) - archive: days/sizeMb 0 no longer treated as unset (0-days archive-everything footgun) - loadConfig: corrupt config.json -> readable error instead of raw stack - forget: corrupt preview snapshot now REJECTS --yes (fail-safe optimistic lock) - annotate_session/save --tag: re-include decision texts in meta_text (viaMeta search surface) --- src/capture/archive.ts | 9 ++++++--- src/cli/forget.ts | 21 ++++++++++++++++++--- src/cli/save.ts | 3 ++- src/mcp/server.ts | 7 ++++++- src/shared/config.ts | 9 ++++++++- 5 files changed, 40 insertions(+), 9 deletions(-) diff --git a/src/capture/archive.ts b/src/capture/archive.ts index 11f7c47..7f3bea4 100644 --- a/src/capture/archive.ts +++ b/src/capture/archive.ts @@ -46,7 +46,10 @@ function isProtected(s: SessionRow): string | null { export function runArchive(db: DB, opts: ArchiveOptions): ArchiveResult { const result: ArchiveResult = { archived: 0, skipped: 0, bytesFreed: 0, details: [] }; const now = new Date(); - const cutoff = opts.days + // [fork 0922] days/sizeMb 为 0 是合法值("归档 0 天前"="全归档"边界语义由调用方定), + // 原实现的 truthy 判断把 0 当"未提供"→ cutoff 退化成 9999-12-31、预算退化成无限—— + // 用户要 0 条实际归档全部(配 --hard 即灾难)。改显式 != null 判定。 + const cutoff = opts.days != null && Number.isFinite(opts.days) ? new Date(now.getTime() - opts.days * 86400_000).toISOString() : opts.before ?? '9999-12-31'; @@ -83,8 +86,8 @@ export function runArchive(db: DB, opts: ArchiveOptions): ArchiveResult { }); } - // 按体积限制(如果指定了 sizeMb) - let bytesBudget = opts.sizeMb ? opts.sizeMb * 1024 * 1024 : Infinity; + // 按体积限制(如果指定了 sizeMb;0 也按 0 尊重,同上) + let bytesBudget = opts.sizeMb != null && Number.isFinite(opts.sizeMb) ? opts.sizeMb * 1024 * 1024 : Infinity; for (const s of candidates) { // 保护规则 diff --git a/src/cli/forget.ts b/src/cli/forget.ts index 15beb7e..37c3cef 100644 --- a/src/cli/forget.ts +++ b/src/cli/forget.ts @@ -37,8 +37,16 @@ interface PreviewSnapshot { const snapshotFile = (root: string) => path.join(relayDir(root), 'forget-pending.json'); -function readSnapshot(root: string): PreviewSnapshot | null { - try { return JSON.parse(fs.readFileSync(snapshotFile(root), 'utf8')) as PreviewSnapshot; } catch { return null; } +function readSnapshot(root: string): { kind: 'missing' } | { kind: 'ok'; snap: PreviewSnapshot } | { kind: 'corrupt' } { + const f = snapshotFile(root); + if (!fs.existsSync(f)) return { kind: 'missing' }; + try { + return { kind: 'ok', snap: JSON.parse(fs.readFileSync(f, 'utf8')) as PreviewSnapshot }; + } catch { + // [fork 0922] 快照损坏 ≠ 快照缺失:乐观锁防线(设计 §3.6)必须 fail-safe—— + // 上次预览被中途打断留下的半截 JSON 不能让 --yes 静默跳过 diff 校验 + return { kind: 'corrupt' }; + } } function writeSnapshot(root: string, s: PreviewSnapshot): void { fs.writeFileSync(snapshotFile(root), JSON.stringify(s, null, 2)); @@ -189,7 +197,14 @@ export async function cmdForget(f: ForgetFlags, refArg?: string): Promise } // ── 执行(--yes):乐观锁 diff(设计 §3.6 并发安全) ── - const snap = readSnapshot(root); + const snapState = readSnapshot(root); + if (snapState.kind === 'corrupt') { + die( + '预览快照损坏(上次预览可能被中途打断),已拒绝执行', + '重新运行不带 --yes 的 forget 预览,生成全新快照后再执行', + ); + } + const snap = snapState.kind === 'ok' ? snapState.snap : null; if (snap && snap.id === row.id) { if (snap.messageCount !== row.messageCount || snap.decisionCount !== decisionCount) { die( diff --git a/src/cli/save.ts b/src/cli/save.ts index fdf6c12..4d6abfa 100644 --- a/src/cli/save.ts +++ b/src/cli/save.ts @@ -84,7 +84,8 @@ export async function cmdSave(f: SaveFlags): Promise { const full = getSessionFull(db, id); if (!full) continue; const merged = [...new Set([...full.userTags, ...tags])]; - const metaText = metaTextOf(full.title, [...full.topics, ...merged, ...(f.summary ? [f.summary] : [])]); + // [fork 0922] meta_text 重写并回决策文本(与 confirm 时一致,防检索面静默变窄) + const metaText = metaTextOf(full.title, [...full.topics, ...merged, ...(f.summary ? [f.summary] : []), ...full.decisions.map((d) => d.text.slice(0, 30))]); db.prepare('UPDATE sessions SET user_tags = ?, user_summary = COALESCE(?, user_summary), meta_text = ? WHERE id = ?') .run(JSON.stringify(merged), f.summary ?? null, metaText, id); } diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 620aa26..c9a38b9 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -330,9 +330,11 @@ export function buildServer(get: () => ServeCtx, select?: (p: string) => Promise if (!full) return toolOut({ ok: false, reason: '会话数据缺失' }); const remove = new Set(args.remove_tags ?? []); const tags = [...new Set([...full.userTags, ...(args.add_tags ?? [])])].filter((t) => !remove.has(t)); + // [fork 0922] 重写 meta_text 必须并回决策文本(confirm 时 meta_text 含决策, + // 原实现覆盖后 viaMeta 检索面静默变窄) db.prepare('UPDATE sessions SET user_tags = ?, user_summary = COALESCE(?, user_summary), meta_text = ? WHERE id = ?') .run(JSON.stringify(tags), args.summary ?? null, - metaTextOf(full.title, [...full.topics, ...tags, ...(args.summary ? [args.summary] : [])]), s.id); + metaTextOf(full.title, [...full.topics, ...tags, ...(args.summary ? [args.summary] : []), ...full.decisions.map((d) => d.text.slice(0, 30))]), s.id); return toolOut({ ok: true, sessionId: s.id, userTags: tags, ...(args.summary ? { userSummary: args.summary } : {}), note: '标签与摘要已进入检索索引' }); })); @@ -511,6 +513,9 @@ class RootHolder { if (this.maySpawn) { const { ensureDaemon } = await import('../cli/ui.js'); ensureDaemon(abs); } const cfg = loadConfig(abs); const db = openExisting(dbFile(abs)); + // [fork 0922] 换根先关旧句柄——原实现直接覆盖 this.cur,旧 better-sqlite3 连接泄漏, + // 且被持有的 relay.sqlite 会让 forget/rebuild 的文件操作报 EBUSY + try { this.cur?.db?.close(); } catch { /* 已关 */ } this.cur = { root: abs, db, cfg, project: cfg.identity.project_id ?? abs }; if (via === 'project') this.tried.project = abs; else this.tried[via] = abs; if (this.maySpawn) touchRegistry(abs); diff --git a/src/shared/config.ts b/src/shared/config.ts index 95c4e05..4fb88ec 100644 --- a/src/shared/config.ts +++ b/src/shared/config.ts @@ -77,7 +77,14 @@ export function loadConfig(root: string): RelayConfig { const def = defaultConfig(); const file = configFile(root); if (!fs.existsSync(file)) return def; - const raw = JSON.parse(fs.readFileSync(file, 'utf8')); + // [fork 0922] 配置损坏时给可读错误而非裸堆栈(doctor 把"config 可解析"当检查项, + // 常规命令路径却拿不到指导信息——原实现 JSON.parse 直接抛) + let raw: Record; + try { + raw = JSON.parse(fs.readFileSync(file, 'utf8')) as Record; + } catch (e) { + throw new Error(`config.json 解析失败(${file}):${(e as Error).message} —— 修复该文件,或删除后 srelay init 重建`); + } return { ...def, ...raw, From 2ab7390c7db507d43008959328767830e8b43159 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 22 Sep 2026 15:22:53 +0800 Subject: [PATCH 09/20] =?UTF-8?q?test:=20defuse=20time-bomb=20in=20forget?= =?UTF-8?q?=20A1=20(fixture=20date=20was=20absolute,=20'=E5=A4=A9=E5=89=8D?= =?UTF-8?q?'=20assertion=20broke=20past=20the=2030-day=20boundary=20on=20S?= =?UTF-8?q?ep=2019)=20-=20suite=20is=20fully=20green=20(222)=20for=20the?= =?UTF-8?q?=20first=20time?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/forget/functional.spec.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/test/forget/functional.spec.ts b/test/forget/functional.spec.ts index 7db431f..78cc2c9 100644 --- a/test/forget/functional.spec.ts +++ b/test/forget/functional.spec.ts @@ -33,10 +33,13 @@ beforeAll(() => { makeProject(PROJECT); process.chdir(PROJECT); const db = createDb(dbFile(PROJECT)); - insertSession(db, { id: S1, source: 'zcode', sourceSessionId: 'z-s1', projectId: PID, createdAt: '2026-08-20T08:00:00Z', title: '数据库选型(PG vs MongoDB)', topics: ['db'] }); - for (let i = 1; i <= 12; i++) insertMessage(db, { sessionId: S1, role: i % 2 ? 'user' : 'assistant', content: i === 3 ? '磁悬浮轴承选型讨论:决定采用 PG' : `消息 ${i}:普通内容`, seqNum: i, createdAt: '2026-08-20T08:00:00Z' }); + // [fork] S1 年龄用相对时间:原夹具写死 2026-08-20,A1 断言"天前"—— + // 写测试时是 N 天前,30 天边界一过(9 月中起)输出变"个月前",套件永久变红(时间炸弹) + const threeDaysAgo = new Date(Date.now() - 3 * 86_400_000).toISOString(); + insertSession(db, { id: S1, source: 'zcode', sourceSessionId: 'z-s1', projectId: PID, createdAt: threeDaysAgo, title: '数据库选型(PG vs MongoDB)', topics: ['db'] }); + for (let i = 1; i <= 12; i++) insertMessage(db, { sessionId: S1, role: i % 2 ? 'user' : 'assistant', content: i === 3 ? '磁悬浮轴承选型讨论:决定采用 PG' : `消息 ${i}:普通内容`, seqNum: i, createdAt: threeDaysAgo }); db.prepare('UPDATE sessions SET message_count = 12, decisions = ?, last_event_at = ? WHERE id = ?') - .run(JSON.stringify([{ text: '决定采用 PostgreSQL', seq: 3 }]), '2026-08-20T09:00:00Z', S1); + .run(JSON.stringify([{ text: '决定采用 PostgreSQL', seq: 3 }]), new Date(Date.now() - 3 * 86_400_000 + 3_600_000).toISOString(), S1); insertSession(db, { id: S1B, source: 'claude-code', sourceSessionId: 'c-s1b', projectId: PID, createdAt: '2026-08-22T08:00:00Z', title: '另一个 a3f 会话', topics: ['misc'] }); insertMessage(db, { sessionId: S1B, role: 'user', content: '无关内容', seqNum: 1, createdAt: '2026-08-22T08:00:00Z' }); db.prepare('UPDATE sessions SET message_count = 1, last_event_at = ? WHERE id = ?').run('2026-08-22T08:00:00Z', S1B); From 1377b124c406851c06b4056a3ab83194e9dc5d4c Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 22 Sep 2026 15:29:35 +0800 Subject: [PATCH 10/20] fork (global mode): dual-write worker logs to each project's own watch.log - per-project logs froze forever under the global daemon, making 'watch --status' print zombie tails in non-cwd projects --- src/capture/watch.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/capture/watch.ts b/src/capture/watch.ts index d65c4bf..73cfad5 100644 --- a/src/capture/watch.ts +++ b/src/capture/watch.ts @@ -198,7 +198,18 @@ export async function runWatchGlobal(opts: { log?: (msg: string) => void } = {}) const key = path.resolve(c.root).toLowerCase(); if (workers.has(key)) continue; try { - const w = await startWatchWorker({ projectRoot: c.root, config: loadConfig(c.root), log: (m) => log(`[${c.name}] ${m}`) }); + // [fork 0922] 日志双写:全局守护的 stderr 全部落在启动项目(cwd)的 watch.log, + // 其余项目的 watch.log 会永久冻结——`srelay watch --status` 在那些项目里打印 + // 僵尸日志尾巴,诊断直呼"守护死了"。worker 日志追加回自己项目的 watch.log。 + const ownLog = path.join(c.root, '.sessionrelay', 'watch.log'); + const w = await startWatchWorker({ + projectRoot: c.root, + config: loadConfig(c.root), + log: (m) => { + log(`[${c.name}] ${m}`); + fs.appendFile(ownLog, `[srelay-watch] ${m}\n`, () => { /* 追加失败不影响运行 */ }); + }, + }); if (w) { workers.set(key, w); log(`${why}: 收编 ${c.root}`); } } catch (e) { log(`${why}: 收编失败 ${c.root}: ${(e as Error).message}`); From 3b7d74f0427e8d80e8dc41e4be261d510929b18b Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 22 Sep 2026 15:44:12 +0800 Subject: [PATCH 11/20] fork (hardening round): 3 root-cures from deep sweep - watcher: runtime 'error' events (dir deleted / overflow / AV) had no listener -> EventEmitter throw kills the whole global daemon; now degrades to the existing 1s polling fallback (self-heal) - insertMessage: index text capped at 100KB (jieba sync tokenize of huge tool-dump messages stalled the event loop seconds); full content still stored, only FTS coverage capped - zcode discover: cache by source-db mtime (idle cycles = 0 SQL instead of unindexed lower() table scan per worker per event) - watch: wire the local anonymous stats counter into the daemon cycle (status panel's 'ignored 0' lied vs 34 real blocks) --- src/adapters/claude-code/watcher.ts | 26 +++++++++++++++++++------- src/adapters/zcode/index.ts | 11 ++++++++++- src/capture/watch.ts | 8 ++++++-- src/store/db.ts | 5 ++++- 4 files changed, 39 insertions(+), 11 deletions(-) diff --git a/src/adapters/claude-code/watcher.ts b/src/adapters/claude-code/watcher.ts index 48de240..fb96724 100644 --- a/src/adapters/claude-code/watcher.ts +++ b/src/adapters/claude-code/watcher.ts @@ -29,13 +29,11 @@ export function watchDir( let watcher: fs.FSWatcher | null = null; let pollTimer: NodeJS.Timeout | null = null; - try { - watcher = fs.watch(dir, { recursive: true }, (_ev, filename) => { - if (!filename) return; - onEvent(path.join(dir, filename.toString())); - }); - } catch { - // Linux 递归不支持 → mtime 轮询兜底(技术方案 §9.1) + // [fork 0922] 轮询兜底提为函数:原本只在 fs.watch 同步创建失败(Linux)时进入, + // 但运行中的 error 事件(目录被删/句柄溢出/杀软干扰)无人监听会让 EventEmitter + // 直接 throw——全局守护一死全部项目捕获停摆。error 时降级轮询自愈。 + const startPolling = () => { + if (pollTimer) return; const snap = new Map(); const scan = () => { try { @@ -59,6 +57,20 @@ export function watchDir( }; scan(); // 建立基线 pollTimer = setInterval(scan, 1000); + }; + + try { + watcher = fs.watch(dir, { recursive: true }, (_ev, filename) => { + if (!filename) return; + onEvent(path.join(dir, filename.toString())); + }); + watcher.on('error', () => { + try { watcher?.close(); } catch { /* 已关 */ } + watcher = null; + startPolling(); // 事件流断了就退化为 1s 轮询,捕获不断线 + }); + } catch { + startPolling(); } return { diff --git a/src/adapters/zcode/index.ts b/src/adapters/zcode/index.ts index 4cd71da..fab2b31 100644 --- a/src/adapters/zcode/index.ts +++ b/src/adapters/zcode/index.ts @@ -27,14 +27,21 @@ export function resetConn(): void { cachedPath = null; } +// [fork 0922] discover 结果缓存:源库未被写入(文件 mtime 未变)时直接复用上一轮结果, +// 空闲周期 0 SQL。原实现每周期对 ZCode 库全表 lower(directory) 扫描(无索引), +// 全局守护 N 个 worker × 每个事件都各扫一遍。 +let discoverCache: { mtimeMs: number; rows: DiscoveredSession[] } | null = null; + export function discover(projectRoot: string, dbPath: string): DiscoveredSession[] { if (!fs.existsSync(dbPath)) return []; + const st = fs.statSync(dbPath); + if (discoverCache && discoverCache.mtimeMs === st.mtimeMs) return discoverCache.rows; const z = getConn(dbPath); try { const rows = z .prepare('SELECT id, title, time_created, time_updated FROM session WHERE lower(directory) = lower(?)') .all(path.resolve(projectRoot)) as Array<{ id: string; title: string; time_created: number; time_updated: number }>; - return rows.map((r) => ({ + const out = rows.map((r) => ({ source: SOURCE_ID, sourceSessionId: r.id, sourceFile: `zcode:${r.id}`, @@ -44,6 +51,8 @@ export function discover(projectRoot: string, dbPath: string): DiscoveredSession sizeBytes: 0, mtimeMs: r.time_updated, })); + discoverCache = { mtimeMs: st.mtimeMs, rows: out }; + return out; } finally { // 使用缓存连接,不关闭(由 resetConn 或进程退出时关闭) } diff --git a/src/capture/watch.ts b/src/capture/watch.ts index 73cfad5..81e17bf 100644 --- a/src/capture/watch.ts +++ b/src/capture/watch.ts @@ -11,7 +11,8 @@ import crypto from 'node:crypto'; import { openExisting } from '../store/db.js'; import type { RelayConfig } from '../shared/config.js'; import { loadConfig } from '../shared/config.js'; -import { projectIdOf, dbFile } from '../shared/paths.js'; +import { projectIdOf, dbFile, statsFile } from '../shared/paths.js'; +import { StatsCounter } from '../core/stats/counter.js'; import { acquireLock, touchLock, releaseLock, isDaemonAlive } from '../shared/lock.js'; import { watchDir } from '../adapters/claude-code/watcher.js'; import { runSync } from './sync.js'; @@ -104,10 +105,13 @@ export async function startWatchWorker(opts: WatchOptions): Promise = Promise.resolve(); // 串行化所有写周期(T23 的进程内体现) + // [fork 0922] 接通本地匿名计数器:守护侧的 ignore 拦截/resumed/confirmed 原来从不计数, + // status 面板"拦截 0 次"与实际不符 + const stats = new StatsCounter(statsFile(root)); const cycle = (why: string) => { chain = chain.then(async () => { try { - const s = await runSync({ projectRoot: root, config: opts.config, db }); + const s = await runSync({ projectRoot: root, config: opts.config, db, stats }); const spool = consumeHookEvents(root, db, new Date()); // R4:hook 事件 → 立即转 pending const j = runJudge(db, { projectId, now: new Date(), idleMin: opts.config.capture.idle_threshold_min, cooldownH: opts.config.capture.cooldown_hours }); if (s.newMessages > 0 || s.resumed > 0 || j.confirmed > 0 || spool.endSignals > 0 || why !== 'tick') { diff --git a/src/store/db.ts b/src/store/db.ts index baf1703..e8c6f86 100644 --- a/src/store/db.ts +++ b/src/store/db.ts @@ -781,10 +781,13 @@ export function insertMessage( db: DB, m: { sessionId: string; role: string; content: string; seqNum: number; createdAt?: string }, ): number { + // [fork 0922] 索引文本截断 100KB:jieba 对超大消息(工具输出转储)同步分词会卡事件循环秒级; + // 100KB 内检索召回完整,超出部分正文仍全量入库、只是不进全文索引 + const idxSource = m.content.length > 100_000 ? m.content.slice(0, 100_000) : m.content; const info = db.prepare(` INSERT OR IGNORE INTO messages (session_id, role, content, search_text, seq_num, created_at) VALUES (?, ?, ?, ?, ?, ?) - `).run(m.sessionId, m.role, m.content, toSearchText(m.content), m.seqNum, m.createdAt ?? null); + `).run(m.sessionId, m.role, m.content, toSearchText(idxSource), m.seqNum, m.createdAt ?? null); return info.changes; } From 8e494501e2775dce4efa41160131a707d7e747ea Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 22 Sep 2026 15:49:42 +0800 Subject: [PATCH 12/20] fork CRITICAL fix: discover cache was keyed by source-db mtime only - with N workers sharing one source db, projects B/C received project A's session list and ingested it as their own (cross-project contamination, live: music 42->559, novel 15->495). Key now includes project root. --- src/adapters/zcode/index.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/adapters/zcode/index.ts b/src/adapters/zcode/index.ts index fab2b31..df5489d 100644 --- a/src/adapters/zcode/index.ts +++ b/src/adapters/zcode/index.ts @@ -30,12 +30,16 @@ export function resetConn(): void { // [fork 0922] discover 结果缓存:源库未被写入(文件 mtime 未变)时直接复用上一轮结果, // 空闲周期 0 SQL。原实现每周期对 ZCode 库全表 lower(directory) 扫描(无索引), // 全局守护 N 个 worker × 每个事件都各扫一遍。 -let discoverCache: { mtimeMs: number; rows: DiscoveredSession[] } | null = null; +// ⚠️ 键必须含项目根:多个项目共享同一源库,只按 mtime 键会让 B 项目拿到 A 项目的 +// 会话列表并错误入库(0922 晚实测污染:music 42→559、novel 15→495)。 +const discoverCache = new Map(); export function discover(projectRoot: string, dbPath: string): DiscoveredSession[] { if (!fs.existsSync(dbPath)) return []; const st = fs.statSync(dbPath); - if (discoverCache && discoverCache.mtimeMs === st.mtimeMs) return discoverCache.rows; + const cacheKey = `${path.resolve(projectRoot).toLowerCase()}\u0000${path.resolve(dbPath).toLowerCase()}`; + const hit = discoverCache.get(cacheKey); + if (hit && hit.mtimeMs === st.mtimeMs) return hit.rows; const z = getConn(dbPath); try { const rows = z @@ -51,7 +55,7 @@ export function discover(projectRoot: string, dbPath: string): DiscoveredSession sizeBytes: 0, mtimeMs: r.time_updated, })); - discoverCache = { mtimeMs: st.mtimeMs, rows: out }; + discoverCache.set(cacheKey, { mtimeMs: st.mtimeMs, rows: out }); return out; } finally { // 使用缓存连接,不关闭(由 resetConn 或进程退出时关闭) From 3e7679e6339658e65f24cba053a49e5f41447990 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 24 Sep 2026 12:53:50 +0800 Subject: [PATCH 13/20] fix(capture): incremental decision refresh + workflow-origin exclusion (4th-project field report) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From a live field report (E:/mc整合包, 4th deployment, direct SQLite forensics): - P0 decisions froze for 20+h on long-lived sessions: extraction only ran at confirm (idle 10min -> pending -> 6h cooldown -> confirm); continuously-worked sessions never completed the loop. Fix: refreshExtraction() now runs at every pending transition (10-min idle), decisions lag <= 10min. confirmSession reuses it. - P1 88% of extracted decisions were workflow-subagent fragments: zcode adapter now tags title-matched subagent sessions with originHint='workflow' -> origin column -> listDecisions excludes them by default (data preserved, queries clean). Live: 36 sessions auto-tagged, visible decisions 82 -> 20, residue 0. - P2 decision text hard-cut at 90 chars produced mid-sentence/trailing-bracket fragments: cut at nearest pause char + trim trailing closers + reject leading-closer fragments. Tests: 222 -> 221 pass + 1 skipped, 0 failures. --- src/adapters/types.ts | 3 +++ src/adapters/zcode/index.ts | 3 +++ src/capture/judge.ts | 9 +++++++-- src/capture/sync.ts | 3 ++- src/core/extract/extract.ts | 14 +++++++++++++- src/store/db.ts | 16 +++++++++++++--- 6 files changed, 41 insertions(+), 7 deletions(-) diff --git a/src/adapters/types.ts b/src/adapters/types.ts index e32a5bf..72f9ee9 100644 --- a/src/adapters/types.ts +++ b/src/adapters/types.ts @@ -73,6 +73,9 @@ export interface DiscoveredSession { updatedAt?: string; sizeBytes: number; mtimeMs: number; + /** [fork 0924] 会话来源提示:适配器识别出"非项目正主"的会话(如工作流子代理)时标注, + * 入库时写入 origin 列,决策检索默认排除——防施工碎片淹没项目决策 */ + originHint?: 'workflow'; } /** 读取结果 */ diff --git a/src/adapters/zcode/index.ts b/src/adapters/zcode/index.ts index df5489d..ea278d0 100644 --- a/src/adapters/zcode/index.ts +++ b/src/adapters/zcode/index.ts @@ -54,6 +54,9 @@ export function discover(projectRoot: string, dbPath: string): DiscoveredSession updatedAt: new Date(r.time_updated).toISOString(), sizeBytes: 0, mtimeMs: r.time_updated, + // [fork 0924] 工作流子代理会话标注 origin=workflow:它们是施工日志不是项目会话, + // 决策提取/检索默认排除(mc整合包实测:88% 决策碎片来自它们) + originHint: /^workflow subagent/i.test(r.title ?? '') ? ('workflow' as const) : undefined, })); discoverCache.set(cacheKey, { mtimeMs: st.mtimeMs, rows: out }); return out; diff --git a/src/capture/judge.ts b/src/capture/judge.ts index 8f033f4..e43c4be 100644 --- a/src/capture/judge.ts +++ b/src/capture/judge.ts @@ -1,7 +1,7 @@ // 判定 tick(方针 §6.1 两阶段提交;Phase 2 将在 confirmed 副作用中加元数据提取与 summary_rule) // now 可注入(技术方案 T5:冷却期逻辑必须可测,不能真等 6 小时) import type { DB } from '../store/db.js'; -import { dueIdle, dueConfirm, markPending, confirmSession } from '../store/db.js'; +import { dueIdle, dueConfirm, markPending, confirmSession, refreshExtraction } from '../store/db.js'; import type { StatsCounter } from '../core/stats/counter.js'; export interface JudgeOptions { @@ -19,7 +19,12 @@ export function runJudge(db: DB, o: JudgeOptions): JudgeResult { const cooldownCutoff = new Date(o.now.getTime() - o.cooldownH * 3_600_000).toISOString(); const idle = dueIdle(db, o.projectId, idleCutoff); - for (const id of idle) markPending(db, id, o.now.toISOString()); + for (const id of idle) { + markPending(db, id, o.now.toISOString()); + // [fork 0924] 1a:提取刷新挂在 pending 转换——空闲 10 分钟即刷新决策, + // 长活会话不再冻结到 6h 确认(mc整合包实测:活跃会话决策滞后 20+ 小时) + refreshExtraction(db, id); + } // confirmed 统一走 confirmSession(提取元数据 + summary_rule + meta_text,方针 §6.6) const toConfirm = dueConfirm(db, o.projectId, cooldownCutoff); diff --git a/src/capture/sync.ts b/src/capture/sync.ts index 1ab7b4a..bfd25ee 100644 --- a/src/capture/sync.ts +++ b/src/capture/sync.ts @@ -231,7 +231,8 @@ async function ingestOne( createdAt: ds.createdAt ?? lastEventAt, lastEventAt, sourceFile: ds.sourceFile, - origin: ctx.origin, + // [fork 0924] 适配器的来源提示(如 zcode 工作流子代理→workflow)优先于默认 auto + origin: ds.originHint ?? ctx.origin, }); if (up.isNew) ctx.result.newSessions++; diff --git a/src/core/extract/extract.ts b/src/core/extract/extract.ts index 0b4ad28..8682e7c 100644 --- a/src/core/extract/extract.ts +++ b/src/core/extract/extract.ts @@ -79,6 +79,7 @@ function plausibleDecision(text: string): boolean { if (/\*\*\s*$/.test(t) || /^\*\*/.test(t)) return false; // Markdown 粗体残片 if ((t.match(/\|/g) ?? []).length >= 2) return false; // 表格行 if (/"(?:confirmed|title|session_id)"\s*:/.test(t) || /\}\s*,\s*\{/.test(t)) return false; // 内嵌 JSON 残片 + if (/^[))】」"'、,。]/.test(t)) return false; // [fork 0924] 以闭括号/标点开头=句子中段截出的残片 return true; } @@ -90,7 +91,18 @@ export function extractDecisions(msgs: Msg[]): ExtractedMeta['decisions'] { for (const re of DECISION_RES) { const hit = s.match(re); if (!hit) continue; - const text = hit[0].replace(/\s+/g, ' ').trim().slice(0, 90); + // [fork 0924] 截断对齐(mc整合包反馈):超长时在最近的停顿处切,再剥尾部括号/引号残片; + // 原 90 字符硬切会产生"石头船放大时质量爆炸增长"这类半句 + let text = hit[0].replace(/\s+/g, ' ').trim(); + if (text.length > 90) { + const w = text.slice(0, 90); + const i = Math.max( + w.lastIndexOf(','), w.lastIndexOf('、'), w.lastIndexOf(';'), + w.lastIndexOf(','), w.lastIndexOf(';'), w.lastIndexOf(' '), + ); + text = i > 40 ? w.slice(0, i) : w; + } + text = text.replace(/[))】」"'、,]+$/u, '').trim(); if (!plausibleDecision(text)) break; // [fork 0922] 残片拒收 const key = text.slice(0, 20); if (seen.has(key)) break; diff --git a/src/store/db.ts b/src/store/db.ts index e8c6f86..7f96b68 100644 --- a/src/store/db.ts +++ b/src/store/db.ts @@ -263,7 +263,7 @@ export interface UpsertCapture { createdAt: string; lastEventAt: string; sourceFile: string; - origin?: 'auto' | 'manual'; + origin?: 'auto' | 'manual' | 'workflow'; } export function upsertCapturedSession( @@ -424,7 +424,9 @@ export function getSessionMessages(db: DB, sessionId: string): Msg[] { } /** confirmed 统一入口(judge 与 srelay confirm 共用):提取元数据 + summary_rule + meta_text 重算 */ -export function confirmSession(db: DB, id: string, at: string): boolean { +/** [fork 0924] 提取刷新(不改变状态):长活会话在 pending 转换时调用—— + * 决策/话题/问题随消息增量对齐,不再冻结到 6h 确认(mc整合包实测:活跃会话决策滞后 20+ 小时) */ +export function refreshExtraction(db: DB, id: string): boolean { const s = getSession(db, id); if (!s) return false; const msgs = getSessionMessages(db, id); // meta 模式无正文 → 提取为空,摘要仅标题/计数 @@ -436,6 +438,13 @@ export function confirmSession(db: DB, id: string, at: string): boolean { lastAt: s.last_event_at, }); applyExtraction(db, id, meta, summary); + return true; +} + +export function confirmSession(db: DB, id: string, at: string): boolean { + const s = getSession(db, id); + if (!s) return false; + if (!refreshExtraction(db, id)) return false; db.prepare(`UPDATE sessions SET state = 'confirmed', confirmed_at = ?, pending_at = NULL WHERE id = ?`).run(at, id); return true; } @@ -466,7 +475,8 @@ export interface DecisionRow { } export function listDecisions(db: DB, projectId: string, filter?: { topic?: string; source?: string }): DecisionRow[] { - const conds = ['project_id = ?', "state = 'confirmed'", 'decisions IS NOT NULL']; + // [fork 0924] 默认排除 origin='workflow'(工作流子代理施工日志,mc整合包实测占决策碎片 88%) + const conds = ['project_id = ?', "state = 'confirmed'", 'decisions IS NOT NULL', "(origin IS NULL OR origin != 'workflow')"]; const params: unknown[] = [projectId]; if (filter?.source) { conds.push('source = ?'); params.push(filter.source); } const rows = db.prepare(`SELECT id, source, title, created_at, decisions, topics FROM sessions WHERE ${conds.join(' AND ')} ORDER BY created_at DESC`).all(...params) as Array<{ From f17f52dc831fbb4a5305442b8e223f6ebe7e42c8 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 24 Sep 2026 12:59:14 +0800 Subject: [PATCH 14/20] fix(capture): applyExtraction re-includes existing user_tags in meta_text (same family as annotate/save fix - confirm/refresh path was the missed third site) --- src/store/db.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/store/db.ts b/src/store/db.ts index 7f96b68..814c8c1 100644 --- a/src/store/db.ts +++ b/src/store/db.ts @@ -451,7 +451,13 @@ export function confirmSession(db: DB, id: string, at: string): boolean { export function applyExtraction(db: DB, id: string, meta: ExtractedMeta, summary: string): void { const s = getSession(db, id); - const topicsAndDecisions = [...meta.topics, ...meta.decisions.map((d) => d.text.slice(0, 30))]; + // [fork 0924] meta_text 重写必须并回既有 user_tags(confirm/refresh 路径同 annotate/save—— + // 否则刷新一次,用户标签就从检索索引里静默消失) + let existingTags: string[] = []; + try { + existingTags = JSON.parse((db.prepare('SELECT user_tags FROM sessions WHERE id = ?').get(id) as { user_tags?: string | null } | undefined)?.user_tags ?? '[]') as string[]; + } catch { /* 空标签 */ } + const topicsAndDecisions = [...meta.topics, ...meta.decisions.map((d) => d.text.slice(0, 30)), ...existingTags]; db.prepare(` UPDATE sessions SET files_mentioned = ?, topics = ?, decisions = ?, key_questions = ?, code_changes = ?, From 3b69246229eaeea26d5ae38c0d083b5feb4e6450 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 24 Sep 2026 13:06:02 +0800 Subject: [PATCH 15/20] fix(query): dedupe unresolved questions across sessions (same question captured twice displayed twice in briefs) --- src/store/db.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/store/db.ts b/src/store/db.ts index 814c8c1..bd438b6 100644 --- a/src/store/db.ts +++ b/src/store/db.ts @@ -508,10 +508,15 @@ export function listUnresolved(db: DB, projectId: string, limit = 20): Unresolve id: string; source: string; title: string | null; created_at: string; key_questions: string; }>; const out: UnresolvedRow[] = []; + const seenQ = new Set(); for (const r of rows) { let qs: Array<{ q: string; at?: string; unresolved: boolean }> = []; try { qs = JSON.parse(r.key_questions); } catch { continue; } for (const q of qs.filter((x) => x.unresolved)) { + // [fork 0924] 同一问题在多个会话被捕获时去重(简报曾同条双显) + const key = (q.q ?? '').replace(/\s+/g, '').slice(0, 40); + if (key && seenQ.has(key)) continue; + if (key) seenQ.add(key); out.push({ q: q.q, at: q.at ?? r.created_at, source: r.source, sessionId: r.id, title: r.title }); } } From 3e00e971b6051b16a17fcbdadb4fb6475442faad Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 24 Sep 2026 20:39:51 +0800 Subject: [PATCH 16/20] =?UTF-8?q?fix(extract):=20anchor=20only=20the=20noi?= =?UTF-8?q?sy=20weak=20verbs=20(=E4=B8=8D=E7=94=A8/=E7=9B=B4=E6=8E=A5?= =?UTF-8?q?=E7=94=A8/=E4=BC=98=E5=85=88=E7=94=A8/=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E7=94=A8)=20to=20sentence=20start;=20=E6=9C=80=E7=BB=88?= =?UTF-8?q?=E7=94=A8/=E6=9C=80=E7=BB=88=E9=80=89=20stay=20strong=20(they?= =?UTF-8?q?=20are=20genuine=20final-choice=20verbs=20-=20caught=20by=20the?= =?UTF-8?q?=20suite)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/extract/extract.ts | 15 +++++++++++++-- src/store/db.ts | 9 ++++++--- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/core/extract/extract.ts b/src/core/extract/extract.ts index 8682e7c..bf1a926 100644 --- a/src/core/extract/extract.ts +++ b/src/core/extract/extract.ts @@ -60,13 +60,21 @@ export function extractTopics(msgs: Msg[]): string[] { } // ── decisions:句式匹配(方针 §6.6:"决定/选择/采用/放弃/最终用") ── +// [fork 0924] 弱触发词(不用/直接用/优先用/统一用)只认句首附近——它们是高频连接词, +// 句中出现时多为普通叙述(mc整合包实测:71% 决策碎片来自这类误命中) +const DECISION_STRONG = /(决定|选择|采用|最终用|最终选|定下|敲定|改为|改用|换成|切换到|升级到|切换成|放弃|弃用)[^。!?!?\n]{2,80}/; +const DECISION_WEAK_ANCHORED = /^[^。!?!?\n]{0,4}(?:不用|直接用|优先用|统一用|改为使用)[^。!?!?\n]{2,80}/; const DECISION_RES: RegExp[] = [ - /(决定|选择|采用|最终用|最终选|优先用|直接用|定下|敲定|改为|改用|换成|切换到|升级到|切换成|统一用|改为使用|放弃|弃用|不用)[^。!?!?\n]{2,80}/, + DECISION_STRONG, + DECISION_WEAK_ANCHORED, /([^。!?!?\n]{2,30}(?:方案|选型|策略|架构|库|协议|格式|命名|引擎|分词|存储|模式)(?:定为|确定为|选定为|采用|敲定))[^。!?!?\n]{0,40}/, ]; function sentences(text: string): string[] { - return text.split(/(?<=[。!?!?\n])/).map((s) => s.trim()).filter(Boolean); + // [fork 0924] 软换行合并:Markdown 段内硬换行不再腰斩句子—— + // 只有"上一行以句末标点收尾"或"下一行是空行"时换行才是切分点 + const merged = text.replace(/([^\n。!?!?])\n(?!\n)/gu, '$1'); + return merged.split(/(?<=[。!?!?]|\n)/).map((s) => s.trim()).filter(Boolean); } // [fork 0922] 决策合理性守卫:正则只管"句首有触发词",不管句子长什么样—— @@ -80,6 +88,9 @@ function plausibleDecision(text: string): boolean { if ((t.match(/\|/g) ?? []).length >= 2) return false; // 表格行 if (/"(?:confirmed|title|session_id)"\s*:/.test(t) || /\}\s*,\s*\{/.test(t)) return false; // 内嵌 JSON 残片 if (/^[))】」"'、,。]/.test(t)) return false; // [fork 0924] 以闭括号/标点开头=句子中段截出的残片 + if (/\*\*/.test(t)) return false; // [fork 0924] 任意位置的粗体残片(mid-text ** 也是格式污染) + const pairs = (t.match(/[((「『【]/g) ?? []).length - (t.match(/[))】」』】]/g) ?? []).length; + if (pairs !== 0) return false; // [fork 0924] 括号不配对=腰斩碎片 return true; } diff --git a/src/store/db.ts b/src/store/db.ts index bd438b6..8b9a3de 100644 --- a/src/store/db.ts +++ b/src/store/db.ts @@ -481,8 +481,10 @@ export interface DecisionRow { } export function listDecisions(db: DB, projectId: string, filter?: { topic?: string; source?: string }): DecisionRow[] { - // [fork 0924] 默认排除 origin='workflow'(工作流子代理施工日志,mc整合包实测占决策碎片 88%) - const conds = ['project_id = ?', "state = 'confirmed'", 'decisions IS NOT NULL', "(origin IS NULL OR origin != 'workflow')"]; + // [fork 0924b] 不再过滤 state='confirmed'——长活会话(一直 active)的决策也要可见, + // 否则决策列再新鲜也进不了简报(mc整合包 0924 实测:791 条消息的活跃会话 9 条决策不可见)。 + // 未确认会话的"半成品"风险由抽取守卫+origin 排除+简报 48h 窗三层兜住。 + const conds = ['project_id = ?', 'decisions IS NOT NULL', "(origin IS NULL OR origin != 'workflow')"]; const params: unknown[] = [projectId]; if (filter?.source) { conds.push('source = ?'); params.push(filter.source); } const rows = db.prepare(`SELECT id, source, title, created_at, decisions, topics FROM sessions WHERE ${conds.join(' AND ')} ORDER BY created_at DESC`).all(...params) as Array<{ @@ -504,7 +506,8 @@ export function listDecisions(db: DB, projectId: string, filter?: { topic?: stri export interface UnresolvedRow { q: string; at: string; source: string; sessionId: string; title: string | null } export function listUnresolved(db: DB, projectId: string, limit = 20): UnresolvedRow[] { - const rows = db.prepare(`SELECT id, source, title, created_at, key_questions FROM sessions WHERE project_id = ? AND key_questions IS NOT NULL ORDER BY created_at DESC`).all(projectId) as Array<{ + // [fork 0924] 同步排除 workflow 施工会话(与 listDecisions 同规,问题 4 的垃圾同源) + const rows = db.prepare(`SELECT id, source, title, created_at, key_questions FROM sessions WHERE project_id = ? AND key_questions IS NOT NULL AND (origin IS NULL OR origin != 'workflow') ORDER BY created_at DESC`).all(projectId) as Array<{ id: string; source: string; title: string | null; created_at: string; key_questions: string; }>; const out: UnresolvedRow[] = []; From 5d76b3f1659cebdfe323b0681eada2710fe57870 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 24 Sep 2026 22:53:37 +0800 Subject: [PATCH 17/20] perf(judge): pending-transition refresh becomes incremental (seq watermark from decisions column, O(delta) instead of O(full session)); topics/summary full rebuild stays at confirm - kills the last recurring CPU/RAM burst generator on long-lived sessions --- src/capture/judge.ts | 9 +++++---- src/store/db.ts | 34 +++++++++++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/capture/judge.ts b/src/capture/judge.ts index e43c4be..7f62c03 100644 --- a/src/capture/judge.ts +++ b/src/capture/judge.ts @@ -1,7 +1,7 @@ // 判定 tick(方针 §6.1 两阶段提交;Phase 2 将在 confirmed 副作用中加元数据提取与 summary_rule) // now 可注入(技术方案 T5:冷却期逻辑必须可测,不能真等 6 小时) import type { DB } from '../store/db.js'; -import { dueIdle, dueConfirm, markPending, confirmSession, refreshExtraction } from '../store/db.js'; +import { dueIdle, dueConfirm, markPending, confirmSession, refreshExtraction, refreshDecisionsIncremental } from '../store/db.js'; import type { StatsCounter } from '../core/stats/counter.js'; export interface JudgeOptions { @@ -21,9 +21,10 @@ export function runJudge(db: DB, o: JudgeOptions): JudgeResult { const idle = dueIdle(db, o.projectId, idleCutoff); for (const id of idle) { markPending(db, id, o.now.toISOString()); - // [fork 0924] 1a:提取刷新挂在 pending 转换——空闲 10 分钟即刷新决策, - // 长活会话不再冻结到 6h 确认(mc整合包实测:活跃会话决策滞后 20+ 小时) - refreshExtraction(db, id); + // [fork 0924] 1a:pending 转换即刷新决策——长活会话不再冻结到 6h 确认。 + // [fork 0924b] 改增量:只提取上次水位之后的新消息(jieba 只扫增量), + // 全量 topics/summary 重建留给 confirm——pending 时不再有 O(全会话) 的 CPU 突发 + refreshDecisionsIncremental(db, id); } // confirmed 统一走 confirmSession(提取元数据 + summary_rule + meta_text,方针 §6.6) diff --git a/src/store/db.ts b/src/store/db.ts index 8b9a3de..17d9e82 100644 --- a/src/store/db.ts +++ b/src/store/db.ts @@ -415,7 +415,7 @@ export function getMessageRange(db: DB, sessionId: string, fromSeq: number, toSe // ───────────────────── Phase 2:confirmed 副作用与元数据查询 ───────────────────── -import { extractMessages, summaryRule, type Msg, type ExtractedMeta } from '../core/extract/extract.js'; +import { extractMessages, extractDecisions, extractQuestions, summaryRule, type Msg, type ExtractedMeta } from '../core/extract/extract.js'; export function getSessionMessages(db: DB, sessionId: string): Msg[] { return (db.prepare('SELECT role, content, seq_num, created_at FROM messages WHERE session_id = ? ORDER BY seq_num') @@ -449,6 +449,38 @@ export function confirmSession(db: DB, id: string, at: string): boolean { return true; } +/** [fork 0924b] 决策/问题增量刷新(pending 转换用,O(增量)): + * 水位 = 决策列里已有的最大 seq;只对新消息跑提取并追加(键去重)。 + * topics/summary/meta_text 的全量重建留给 confirm(6h 一次,成本可摊)。 + * 没有水位的会话(从未提取过)退化为全量——与旧行为一致。 */ +export function refreshDecisionsIncremental(db: DB, id: string): boolean { + const s = getSession(db, id); + if (!s) return false; + let existing: Array<{ text: string; seq: number; at?: string }> = []; + try { existing = JSON.parse((db.prepare('SELECT decisions FROM sessions WHERE id = ?').get(id) as { decisions?: string } | undefined)?.decisions ?? '[]'); } catch { existing = []; } + const lastSeq = existing.reduce((m, d) => Math.max(m, d.seq ?? 0), 0); + const msgs = (db.prepare('SELECT role, content, seq_num AS seqNum, created_at AS createdAt FROM messages WHERE session_id = ? AND seq_num > ? ORDER BY seq_num') + .all(id, lastSeq) as Msg[]).map((m) => ({ ...m, role: m.role === 'user' ? 'user' as const : 'assistant' as const })); + const newDecs = extractDecisions(msgs).filter((d) => !existing.some((e) => e.text === d.text)); + const newQs = extractQuestions(msgs); + if (newDecs.length === 0 && newQs.length === 0) return true; + const mergedDecs = [...existing, ...newDecs]; + let questions: Array<{ q: string; seq: number; at?: string; unresolved: boolean }> = []; + try { questions = JSON.parse((db.prepare('SELECT key_questions FROM sessions WHERE id = ?').get(id) as { key_questions?: string } | undefined)?.key_questions ?? '[]'); } catch { questions = []; } + const qKeys = new Set(questions.map((q) => (q.q ?? '').slice(0, 40))); + const mergedQs = [...questions, ...newQs.filter((q) => !qKeys.has((q.q ?? '').slice(0, 40)))]; + const existingTags = (() => { + const row = db.prepare('SELECT user_tags, topics FROM sessions WHERE id = ?').get(id) as { user_tags?: string | null; topics?: string | null } | undefined; + try { return JSON.parse(row?.user_tags ?? '[]') as string[]; } catch { return []; } + })(); + let existingTopics: string[] = []; + try { existingTopics = JSON.parse((db.prepare('SELECT topics FROM sessions WHERE id = ?').get(id) as { topics?: string | null } | undefined)?.topics ?? '[]') as string[]; } catch { existingTopics = []; } + const topicsAndDecisions = [...existingTopics, ...mergedDecs.map((d) => d.text.slice(0, 30)), ...existingTags]; + db.prepare('UPDATE sessions SET decisions = ?, key_questions = ?, meta_text = ? WHERE id = ?') + .run(JSON.stringify(mergedDecs), JSON.stringify(mergedQs), metaTextOf(s.title, topicsAndDecisions), id); + return true; +} + export function applyExtraction(db: DB, id: string, meta: ExtractedMeta, summary: string): void { const s = getSession(db, id); // [fork 0924] meta_text 重写必须并回既有 user_tags(confirm/refresh 路径同 annotate/save—— From 80e3d5aa8ea78c4cf6b83eed99517d266e4910e8 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 25 Sep 2026 00:23:40 +0800 Subject: [PATCH 18/20] fix(extract): question extraction gets a fragment guard too (list/table/bold residue rejected); briefs read unresolved entries via q field (title-only display was a silent regression from the --json fix) --- src/core/extract/extract.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/core/extract/extract.ts b/src/core/extract/extract.ts index bf1a926..1b10828 100644 --- a/src/core/extract/extract.ts +++ b/src/core/extract/extract.ts @@ -139,6 +139,8 @@ export function extractQuestions(msgs: Msg[]): ExtractedMeta['questions'] { if (m.role !== 'user') return; for (const s of sentences(m.content)) { if (!Q_MARK.test(s) || s.length < 8 || !Q_HINT.test(s)) continue; + // [fork 0924] 问题也要过碎片守卫:列表项/表格行/粗体残片不是真问题(mc 整合包反馈同族) + if (/^\s*([-*#>))」]|&&)/.test(s) || /\|\s*-{2,}/.test(s) || /\*\*/.test(s)) continue; const tailAsked = idx >= Math.floor(total * 0.7); // 会话尾部提问,大概率未被回答 out.push({ q: s.replace(/\s+/g, ' ').trim().slice(0, 90), From e4b0ba6ca483b52930fcea10dcd1ee1bf0ff6439 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 25 Sep 2026 00:28:09 +0800 Subject: [PATCH 19/20] fix(watch): wire the anonymous stats counter into runJudge (daemon-side confirm counts were invisible in the status panel) --- src/capture/watch.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/capture/watch.ts b/src/capture/watch.ts index 81e17bf..c14e95a 100644 --- a/src/capture/watch.ts +++ b/src/capture/watch.ts @@ -113,7 +113,7 @@ export async function startWatchWorker(opts: WatchOptions): Promise 0 || s.resumed > 0 || j.confirmed > 0 || spool.endSignals > 0 || why !== 'tick') { log(`${why}: +${s.newMessages} 消息 · resumed ${s.resumed} · pending ${j.toPending} · confirmed ${j.confirmed}${spool.endSignals ? ` · hook信号 ${spool.endSignals}` : ''}`); } From 691084f41ef9b7ed362994f89ef55ac462c437d9 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 25 Sep 2026 00:35:46 +0800 Subject: [PATCH 20/20] test: regression locks for fork additions - origin=workflow exclusion and incremental decision refresh (seq-watermark idempotency); guards the mc-report root-cures against silent breakage --- test/fork/hardening.spec.ts | 67 +++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 test/fork/hardening.spec.ts diff --git a/test/fork/hardening.spec.ts b/test/fork/hardening.spec.ts new file mode 100644 index 0000000..0d46c3e --- /dev/null +++ b/test/fork/hardening.spec.ts @@ -0,0 +1,67 @@ +// [fork 0924] 本 fork 新增逻辑的回归锁:origin=workflow 排除 + 决策增量刷新(水位幂等) +// 这两个行为是 mc整合包 实测反馈的根治产物——没有测试锁定就会被无意破坏 +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createDb, upsertCapturedSession, insertMessage, markPending, refreshDecisionsIncremental, confirmSession, listDecisions } from '../../src/store/db.js'; + +let TMP = ''; +let db: ReturnType; +const PID = 'proj-fork-test'; +const ids: Record = {}; + +const ins = (key: string, title: string, origin: 'auto' | 'workflow', msgs: Array<[number, string]>) => { + const up = upsertCapturedSession(db, { + source: 'zcode', sourceSessionId: 'src-' + key, projectId: PID, title, + createdAt: new Date().toISOString(), lastEventAt: new Date().toISOString(), origin, + }); + ids[key] = up.id; + for (const [seq, content] of msgs) { + insertMessage(db, { sessionId: up.id, role: seq % 2 ? 'user' : 'assistant', content, seqNum: seq, createdAt: new Date().toISOString() }); + } + db.prepare('UPDATE sessions SET message_count = ? WHERE id = ?').run(msgs.length, up.id); +}; + +beforeEach(() => { + TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'forkrt-')); + fs.mkdirSync(path.join(TMP, '.sessionrelay'), { recursive: true }); + process.chdir(TMP); + db = createDb(path.join(TMP, '.sessionrelay', 'relay.sqlite')); +}); +afterEach(() => { + process.chdir(os.tmpdir()); + try { db.close(); } catch { /* 已关 */ } + for (let i = 0; i < 3; i++) { try { fs.rmSync(TMP, { recursive: true, force: true }); break; } catch { /* Windows 句柄延迟重试 */ } } +}); + +describe('fork · origin=workflow 排除', () => { + it('施工会话的决策不进 listDecisions,正主会话的进', () => { + ins('wf', 'workflow subagent actor#1@1', 'workflow', [[1, '决定主题能不能实现']]); + ins('main', '正主会话', 'auto', [[1, '决定采用 PostgreSQL 作为主库']]); + markPending(db, ids.wf, new Date().toISOString()); + markPending(db, ids.main, new Date().toISOString()); + refreshDecisionsIncremental(db, ids.wf); + refreshDecisionsIncremental(db, ids.main); + confirmSession(db, ids.wf, new Date().toISOString()); + confirmSession(db, ids.main, new Date().toISOString()); + const rows = listDecisions(db, PID); + expect(rows.length).toBeGreaterThanOrEqual(1); + expect(rows.every((r) => !/workflow subagent/.test(r.title ?? ''))).toBe(true); + }); +}); + +describe('fork · 决策增量刷新', () => { + it('水位之后的新消息追加决策,且幂等去重', () => { + ins('inc', '增量会话', 'auto', [[1, '决定采用方案 A']]); + confirmSession(db, ids.inc, new Date().toISOString()); + insertMessage(db, { sessionId: ids.inc, role: 'assistant', content: '决定追加方案 B 作为兜底', seqNum: 2, createdAt: new Date().toISOString() }); + refreshDecisionsIncremental(db, ids.inc); + refreshDecisionsIncremental(db, ids.inc); // 幂等:二次调用不重复追加 + const rows = listDecisions(db, PID); + const texts = rows.map((r) => r.text); + expect(texts.some((t) => t.includes('方案 A'))).toBe(true); + expect(texts.some((t) => t.includes('方案 B'))).toBe(true); + expect(texts.filter((t) => t.includes('方案 B')).length).toBe(1); + }); +});