minimal-agent 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -1
- package/plugins/workflow-runner/plugin.js +27 -15
- package/plugins/workflow-runner/plugin.ts +34 -20
- package/plugins/workflow-runner/src/agentCommon.js +13 -12
- package/plugins/workflow-runner/src/agentCommon.ts +22 -13
- package/plugins/workflow-runner/src/expressions.js +29 -8
- package/plugins/workflow-runner/src/expressions.ts +28 -10
- package/plugins/workflow-runner/src/index.js +50 -20
- package/plugins/workflow-runner/src/index.ts +75 -31
- package/plugins/workflow-runner/src/multimodal.js +2 -1
- package/plugins/workflow-runner/src/multimodal.ts +2 -1
- package/plugins/workflow-runner/src/recovery.js +9 -2
- package/plugins/workflow-runner/src/recovery.ts +9 -2
- package/plugins/workflow-runner/src/runner.js +31 -5
- package/plugins/workflow-runner/src/runner.ts +33 -5
- package/plugins/workflow-runner/src/stepExecutors/assert.js +2 -1
- package/plugins/workflow-runner/src/stepExecutors/assert.ts +3 -1
- package/plugins/workflow-runner/src/stepExecutors/skill.js +5 -6
- package/plugins/workflow-runner/src/stepExecutors/skill.ts +12 -7
- package/plugins/workflow-runner/src/stepExecutors/tool.js +91 -61
- package/plugins/workflow-runner/src/stepExecutors/tool.ts +111 -71
- package/plugins/workflow-runner/src/stepExecutors/verify.js +6 -3
- package/plugins/workflow-runner/src/stepExecutors/verify.ts +7 -3
- package/plugins/workflow-runner/src/taskIdentity.js +197 -21
- package/plugins/workflow-runner/src/taskIdentity.ts +201 -20
- package/plugins/workflow-runner/src/toolRuntime.js +48 -0
- package/plugins/workflow-runner/src/toolRuntime.ts +64 -0
- package/plugins/workflow-runner/src/types.js +113 -7
- package/plugins/workflow-runner/src/types.ts +137 -14
- package/plugins/workflow-runner/src/workflowState.js +455 -54
- package/plugins/workflow-runner/src/workflowState.ts +508 -63
- package/plugins/workflow-runner/test/cli.e2e.test.ts +20 -0
- package/plugins/workflow-runner/test/context-files.test.ts +47 -16
- package/plugins/workflow-runner/test/e2eNewLayout.test.ts +314 -0
- package/plugins/workflow-runner/test/engineRobustness.test.ts +9 -1
- package/plugins/workflow-runner/test/expressions.test.ts +42 -0
- package/plugins/workflow-runner/test/hostCapability.test.ts +9 -3
- package/plugins/workflow-runner/test/illustrate.load.test.ts +2 -2
- package/plugins/workflow-runner/test/multimodal.test.ts +61 -10
- package/plugins/workflow-runner/test/parseWorkflowArgs.test.ts +19 -0
- package/plugins/workflow-runner/test/ppt.load.test.ts +15 -15
- package/plugins/workflow-runner/test/ralphLoop.e2e.test.ts +3 -2
- package/plugins/workflow-runner/test/research.load.test.ts +328 -0
- package/plugins/workflow-runner/test/runnerPreflight.test.ts +68 -11
- package/plugins/workflow-runner/test/stateLifecycle.test.ts +60 -12
- package/plugins/workflow-runner/test/taskArbitration.test.ts +19 -3
- package/plugins/workflow-runner/test/taskLifecycle.test.ts +61 -7
- package/plugins/workflow-runner/test/toolRuntimeIntegration.test.ts +168 -0
- package/plugins/workflow-runner/test/webppt.load.test.ts +4 -4
- package/plugins/workflow-runner/test/word.load.test.ts +9 -9
- package/skills/skill-creator/scripts/run_eval.py +3 -2
- package/src/cli/print.js +36 -4
- package/src/cli/print.ts +42 -4
- package/src/cli/streamJson.js +19 -4
- package/src/cli/streamJson.ts +17 -4
- package/src/config/configDefaults.js +25 -0
- package/src/config/configDefaults.ts +33 -0
- package/src/config/configFile.js +2 -21
- package/src/config/configFile.ts +11 -24
- package/src/config/configManagement.js +1 -1
- package/src/config/configManagement.ts +2 -2
- package/src/config/configService.js +24 -0
- package/src/config/configService.ts +23 -0
- package/src/context/compact.js +4 -0
- package/src/context/compact.ts +4 -0
- package/src/context/contextBundle.js +38 -13
- package/src/context/contextBundle.ts +47 -13
- package/src/harness/stallGuard.js +19 -4
- package/src/harness/stallGuard.ts +30 -7
- package/src/infra/verification.js +25 -18
- package/src/infra/verification.ts +50 -18
- package/src/loop.js +95 -11
- package/src/loop.ts +125 -16
- package/src/main.js +8 -1
- package/src/main.tsx +7 -1
- package/src/memory/constants.js +10 -0
- package/src/memory/constants.ts +13 -0
- package/src/memory/episodicMemory.js +274 -2
- package/src/memory/episodicMemory.ts +306 -1
- package/src/memory/fileStore.js +24 -4
- package/src/memory/fileStore.ts +22 -2
- package/src/memory/memoryService.js +79 -12
- package/src/memory/memoryService.ts +108 -9
- package/src/memory/repository.js +415 -85
- package/src/memory/repository.ts +437 -86
- package/src/memory/turnJournal.js +95 -0
- package/src/memory/turnJournal.ts +120 -0
- package/src/memory/types.ts +16 -0
- package/src/plugins/pluginApi.ts +30 -6
- package/src/plugins/pluginRunner.js +23 -8
- package/src/plugins/pluginRunner.ts +32 -13
- package/src/prompts/policyRegistry.js +55 -1
- package/src/prompts/policyRegistry.ts +55 -1
- package/src/prompts/promptAssembly.js +39 -7
- package/src/prompts/promptAssembly.ts +145 -59
- package/src/session/runAgentTurn.js +43 -21
- package/src/session/runAgentTurn.ts +68 -26
- package/src/session/runTurn.js +11 -46
- package/src/session/runTurn.ts +12 -46
- package/src/session/runtimeStore.js +149 -0
- package/src/session/runtimeStore.ts +197 -0
- package/src/session/sessionState.js +5 -0
- package/src/session/sessionState.ts +14 -0
- package/src/tools/advisor/advisor.js +76 -9
- package/src/tools/advisor/advisor.ts +79 -9
- package/src/tools/agent/agent.js +143 -14
- package/src/tools/agent/agent.ts +149 -14
- package/src/tools/agent/agentTaskService.js +60 -0
- package/src/tools/agent/agentTaskService.ts +113 -0
- package/src/tools/agent/agentTypes.js +5 -0
- package/src/tools/agent/agentTypes.ts +5 -0
- package/src/tools/bash/bash.js +80 -15
- package/src/tools/bash/bash.ts +83 -15
- package/src/tools/capabilities.js +5 -0
- package/src/tools/capabilities.ts +11 -1
- package/src/tools/edit/edit.js +7 -4
- package/src/tools/edit/edit.ts +7 -4
- package/src/tools/edit/multi-edit.js +4 -2
- package/src/tools/edit/multi-edit.ts +4 -2
- package/src/tools/glob/glob.js +156 -40
- package/src/tools/glob/glob.ts +185 -46
- package/src/tools/index.js +35 -17
- package/src/tools/index.ts +81 -20
- package/src/tools/lsp/lsp.js +165 -22
- package/src/tools/lsp/lsp.ts +206 -36
- package/src/tools/powershell/powershell.js +134 -27
- package/src/tools/powershell/powershell.ts +171 -30
- package/src/tools/presentresult/presentresult.js +10 -0
- package/src/tools/presentresult/presentresult.ts +10 -0
- package/src/tools/read/read.js +249 -29
- package/src/tools/read/read.ts +396 -59
- package/src/tools/shared/diffSummary.js +44 -0
- package/src/tools/shared/diffSummary.ts +63 -0
- package/src/tools/shared/fileActivity.js +2 -1
- package/src/tools/shared/fileActivity.ts +3 -0
- package/src/tools/shared/permanentDeleteGuard.js +57 -0
- package/src/tools/shared/permanentDeleteGuard.ts +71 -0
- package/src/tools/shared/toolOutputStore.js +97 -0
- package/src/tools/shared/toolOutputStore.ts +110 -0
- package/src/tools/skill/skill.js +86 -0
- package/src/tools/skill/skill.ts +94 -0
- package/src/tools/snip/snip.js +67 -0
- package/src/tools/snip/snip.ts +80 -0
- package/src/tools/tasks/backgroundTaskService.js +79 -28
- package/src/tools/tasks/backgroundTaskService.ts +165 -49
- package/src/tools/tasks/taskManagement.js +163 -0
- package/src/tools/tasks/taskManagement.ts +180 -0
- package/src/tools/tasks/taskOutput.js +64 -14
- package/src/tools/tasks/taskOutput.ts +102 -23
- package/src/tools/tasks/taskStop.js +48 -8
- package/src/tools/tasks/taskStop.ts +69 -9
- package/src/tools/tasks/taskStore.js +46 -0
- package/src/tools/tasks/taskStore.ts +77 -0
- package/src/tools/terminal/terminalCapture.js +167 -0
- package/src/tools/terminal/terminalCapture.ts +179 -0
- package/src/tools/trash/trash.js +233 -0
- package/src/tools/trash/trash.ts +283 -0
- package/src/tools/webbrowser/browserControl.js +5 -1
- package/src/tools/webbrowser/browserControl.ts +7 -1
- package/src/tools/webbrowser/webbrowser.js +8 -4
- package/src/tools/webbrowser/webbrowser.ts +8 -4
- package/src/tools/worktree/sessionWorktree.js +128 -0
- package/src/tools/worktree/sessionWorktree.ts +158 -0
- package/src/tools/write/write.js +6 -2
- package/src/tools/write/write.ts +6 -2
- package/src/types.ts +22 -4
- package/src/ui/hooks/useChat.js +31 -11
- package/src/ui/hooks/useChat.ts +31 -13
- package/src/web/protocol.ts +78 -3
- package/workflows/adaptive-research.yaml +5 -5
- package/workflows/book-review-short.yaml +5 -5
- package/workflows/e2e-write-greet.yaml +2 -2
- package/workflows/illustrate.yaml +12 -12
- package/workflows/plan.yaml +18 -18
- package/workflows/ppt.yaml +201 -201
- package/workflows/ralph-loop.yaml +3 -1
- package/workflows/research.md +449 -0
- package/workflows/research.yaml +873 -0
- package/workflows/webppt.yaml +47 -47
- package/workflows/word.yaml +80 -80
- package/workflows/youtube-shorts.yaml +25 -25
package/src/memory/repository.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { watch, type FSWatcher } from 'node:fs';
|
|
2
2
|
import { mkdir, readFile, readdir, rename, rm, stat, unlink } from 'node:fs/promises';
|
|
3
|
-
import { dirname,
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
5
|
|
|
6
|
+
import { pathContains } from '../utils/pathContains.js';
|
|
7
|
+
|
|
6
8
|
import { parseMemoryRecord, serializeMemoryRecord, sha256 } from './codec.js';
|
|
7
9
|
import { ALL_CATEGORIES, CATEGORY_DIR } from './constants.js';
|
|
8
10
|
import {
|
|
@@ -30,8 +32,10 @@ interface CachedFile {
|
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
const fileCache = new Map<string, CachedFile>();
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
+
/** 兜底审计的最小间隔:watcher 漏掉的变更最迟这么久后会被 stat 审计发现。 */
|
|
36
|
+
const SCOPE_AUDIT_TTL_MS = 1_000;
|
|
37
|
+
/** 「扫描期间被改动 → 重扫」的重试上限,防止持续写入把读拖成活锁。 */
|
|
38
|
+
const MAX_SCAN_RETRIES = 3;
|
|
35
39
|
const MAX_SCOPE_CACHE_ENTRIES = 128;
|
|
36
40
|
|
|
37
41
|
const repositoryDiagnostics = {
|
|
@@ -52,18 +56,69 @@ export function resetMemoryRepositoryDiagnostics(): void {
|
|
|
52
56
|
repositoryDiagnostics.fullScans = 0;
|
|
53
57
|
}
|
|
54
58
|
|
|
55
|
-
interface
|
|
59
|
+
interface ScopeCacheEntry {
|
|
56
60
|
snapshot: MemorySnapshot;
|
|
57
|
-
checkedAt: number;
|
|
58
61
|
dirty: boolean;
|
|
59
|
-
watcherActive: boolean;
|
|
60
62
|
directoryFingerprint: string;
|
|
61
63
|
lastAuditAt: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface ScopeRuntimeState {
|
|
67
|
+
cache?: ScopeCacheEntry;
|
|
68
|
+
watchers: FSWatcher[];
|
|
69
|
+
watcherComplete: boolean;
|
|
70
|
+
watcherRefreshRequired: boolean;
|
|
71
|
+
epoch: number;
|
|
62
72
|
lastUsedAt: number;
|
|
73
|
+
lastDrainAt: number;
|
|
74
|
+
suppressDirtyUntil: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const scopeStates = new Map<string, ScopeRuntimeState>();
|
|
78
|
+
|
|
79
|
+
function scopeState(key: string): ScopeRuntimeState {
|
|
80
|
+
let state = scopeStates.get(key);
|
|
81
|
+
if (!state) {
|
|
82
|
+
state = {
|
|
83
|
+
watchers: [],
|
|
84
|
+
watcherComplete: false,
|
|
85
|
+
watcherRefreshRequired: false,
|
|
86
|
+
epoch: 0,
|
|
87
|
+
lastUsedAt: 0,
|
|
88
|
+
lastDrainAt: 0,
|
|
89
|
+
suppressDirtyUntil: 0,
|
|
90
|
+
};
|
|
91
|
+
scopeStates.set(key, state);
|
|
92
|
+
}
|
|
93
|
+
return state;
|
|
63
94
|
}
|
|
64
95
|
|
|
65
|
-
|
|
66
|
-
const
|
|
96
|
+
/** scope 目录下由本模块自己生成、不该被当成「用户改了记忆」的条目。 */
|
|
97
|
+
const GENERATED_SCOPE_ENTRIES: ReadonlySet<string> = new Set([
|
|
98
|
+
'views',
|
|
99
|
+
'.transactions',
|
|
100
|
+
'.records.lock',
|
|
101
|
+
]);
|
|
102
|
+
const CATEGORY_SCOPE_ENTRIES = new Set(Object.values(CATEGORY_DIR));
|
|
103
|
+
const WATCHER_SETTLE_REUSE_MS = 50;
|
|
104
|
+
const WATCHER_SETTLE_WAIT_MS = 20;
|
|
105
|
+
const INTERNAL_RECORD_EVENT_TTL_MS = 1_000;
|
|
106
|
+
const internalRecordEvents = new Map<string, number>();
|
|
107
|
+
|
|
108
|
+
function markInternalRecordEvent(path: string): void {
|
|
109
|
+
internalRecordEvents.set(path, Date.now() + INTERNAL_RECORD_EVENT_TTL_MS);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function consumeInternalRecordEvent(path: string): boolean {
|
|
113
|
+
const expiresAt = internalRecordEvents.get(path);
|
|
114
|
+
if (!expiresAt) return false;
|
|
115
|
+
if (expiresAt < Date.now()) {
|
|
116
|
+
internalRecordEvents.delete(path);
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
internalRecordEvents.delete(path);
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
67
122
|
|
|
68
123
|
interface MemoryTransactionItem {
|
|
69
124
|
id: string;
|
|
@@ -84,9 +139,13 @@ function transactionRoot(base: string): string { return join(base, '.transaction
|
|
|
84
139
|
function transactionManifestPath(base: string): string { return join(transactionRoot(base), 'current.json'); }
|
|
85
140
|
function generationPath(base: string): string { return join(transactionRoot(base), 'generation'); }
|
|
86
141
|
|
|
142
|
+
/**
|
|
143
|
+
* 「这个路径在不在那个目录里」只有一份实现:`src/utils/pathContains.ts`。
|
|
144
|
+
* 这里只负责把两边解析成绝对路径(manifest 里的路径来自 JSON,可能是相对的),
|
|
145
|
+
* 判据本身不再手写第五遍——那个 util 的头注释专门警告过手写副本会各自漂移。
|
|
146
|
+
*/
|
|
87
147
|
function pathInside(base: string, path: string): boolean {
|
|
88
|
-
|
|
89
|
-
return rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
148
|
+
return pathContains(resolve(base), resolve(path));
|
|
90
149
|
}
|
|
91
150
|
|
|
92
151
|
async function readGeneration(base: string): Promise<string> {
|
|
@@ -99,7 +158,12 @@ async function readTransactionManifest(base: string): Promise<MemoryTransactionM
|
|
|
99
158
|
throw error;
|
|
100
159
|
});
|
|
101
160
|
if (raw === null) return null;
|
|
102
|
-
|
|
161
|
+
let parsed: MemoryTransactionManifest;
|
|
162
|
+
try {
|
|
163
|
+
parsed = JSON.parse(raw) as MemoryTransactionManifest;
|
|
164
|
+
} catch {
|
|
165
|
+
throw new Error('MEMORY_TRANSACTION_CORRUPT');
|
|
166
|
+
}
|
|
103
167
|
if (parsed.schemaVersion !== 1 || !parsed.id || !Array.isArray(parsed.items)) {
|
|
104
168
|
throw new Error('MEMORY_TRANSACTION_CORRUPT');
|
|
105
169
|
}
|
|
@@ -129,6 +193,8 @@ async function rollForwardTransactionUnlocked(base: string): Promise<void> {
|
|
|
129
193
|
throw new Error('MEMORY_TRANSACTION_CONFLICT');
|
|
130
194
|
}
|
|
131
195
|
await mkdir(dirname(item.nextPath), { recursive: true, mode: 0o700 });
|
|
196
|
+
markInternalRecordEvent(item.nextPath);
|
|
197
|
+
if (item.oldPath !== item.nextPath) markInternalRecordEvent(item.oldPath);
|
|
132
198
|
await rename(item.stagePath, item.nextPath);
|
|
133
199
|
if (item.oldPath !== item.nextPath) await unlink(item.oldPath).catch(() => {});
|
|
134
200
|
}
|
|
@@ -137,91 +203,332 @@ async function rollForwardTransactionUnlocked(base: string): Promise<void> {
|
|
|
137
203
|
await rm(join(transactionRoot(base), manifest.id), { recursive: true, force: true });
|
|
138
204
|
}
|
|
139
205
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
206
|
+
const TRANSACTION_POISON_ERRORS = new Set([
|
|
207
|
+
'MEMORY_TRANSACTION_CORRUPT',
|
|
208
|
+
'MEMORY_TRANSACTION_PATH_INVALID',
|
|
209
|
+
'MEMORY_TRANSACTION_CONFLICT',
|
|
210
|
+
]);
|
|
211
|
+
|
|
212
|
+
interface ScopeRecovery {
|
|
213
|
+
recovered: boolean;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
interface QuarantineMetadata {
|
|
217
|
+
schemaVersion: 1;
|
|
218
|
+
quarantinedAt: number;
|
|
219
|
+
reason: string;
|
|
220
|
+
originalManifestPath: string;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function quarantineDiagnosticsFor(base: string): Promise<MemoryDiagnostic[]> {
|
|
224
|
+
const root = transactionRoot(base);
|
|
225
|
+
let names: string[];
|
|
226
|
+
try {
|
|
227
|
+
names = (await readdir(root, { withFileTypes: true }))
|
|
228
|
+
.filter((entry) => entry.isDirectory() && entry.name.startsWith('quarantine-'))
|
|
229
|
+
.map((entry) => entry.name);
|
|
230
|
+
} catch (error) {
|
|
231
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
|
|
232
|
+
throw error;
|
|
233
|
+
}
|
|
234
|
+
const diagnostics: MemoryDiagnostic[] = [];
|
|
235
|
+
for (const name of names.sort()) {
|
|
236
|
+
const target = join(root, name);
|
|
237
|
+
let metadata: QuarantineMetadata | null = null;
|
|
238
|
+
try {
|
|
239
|
+
metadata = JSON.parse(await readFile(join(target, 'diagnostic.json'), 'utf8')) as QuarantineMetadata;
|
|
240
|
+
} catch {
|
|
241
|
+
// 旧版本隔离目录没有元数据时仍需告警,不能因升级让诊断沉默。
|
|
242
|
+
}
|
|
243
|
+
const reason = metadata?.reason ?? 'MEMORY_TRANSACTION_QUARANTINED';
|
|
244
|
+
diagnostics.push({
|
|
245
|
+
path: metadata?.originalManifestPath ?? join(target, 'current.json'),
|
|
246
|
+
message:
|
|
247
|
+
`记忆事务无法前滚(${reason}),已隔离到 ${target} 并继续读取现有记录;`
|
|
248
|
+
+ '该批次的改动可能只应用了一部分,请复核相关记忆。',
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
return diagnostics;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export async function clearScopeTransactionQuarantine(
|
|
255
|
+
scope: MemoryScope,
|
|
256
|
+
workspaceId?: string,
|
|
257
|
+
): Promise<void> {
|
|
258
|
+
const root = transactionRoot(scopeDirectory(scope, workspaceId));
|
|
259
|
+
let names: string[] = [];
|
|
260
|
+
try {
|
|
261
|
+
names = (await readdir(root, { withFileTypes: true }))
|
|
262
|
+
.filter((entry) => entry.isDirectory() && entry.name.startsWith('quarantine-'))
|
|
263
|
+
.map((entry) => entry.name);
|
|
264
|
+
} catch (error) {
|
|
265
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
266
|
+
}
|
|
267
|
+
await Promise.all(names.map(async (name) => {
|
|
268
|
+
const quarantine = join(root, name);
|
|
269
|
+
try {
|
|
270
|
+
const manifest = JSON.parse(await readFile(join(quarantine, 'current.json'), 'utf8')) as { id?: unknown };
|
|
271
|
+
if (typeof manifest.id === 'string' && /^[a-f0-9-]{16,}$/i.test(manifest.id)) {
|
|
272
|
+
await rm(join(root, manifest.id), { recursive: true, force: true });
|
|
273
|
+
}
|
|
274
|
+
} catch {
|
|
275
|
+
// 损坏清单本来就可能无法解析;清理隔离目录仍继续。
|
|
276
|
+
}
|
|
277
|
+
await rm(quarantine, { recursive: true, force: true });
|
|
278
|
+
}));
|
|
279
|
+
invalidateScope(scope, workspaceId);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** 隔离操作的专用锁路径(实际锁文件是 `<它>.lock`)。 */
|
|
283
|
+
function quarantineLockPath(base: string): string {
|
|
284
|
+
return join(transactionRoot(base), '.quarantine');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* 把无法前滚的事务清单挪进 `.transactions/quarantine-<ts>/`,让读路径继续可用。
|
|
289
|
+
*
|
|
290
|
+
* ⚠️ 这是**读路径**独有的策略;写路径(replaceMemoryBatchVersioned)仍然照常抛错——
|
|
291
|
+
* 写入方必须看到冲突,不能替它把别人已提交的事务悄悄扔掉。
|
|
292
|
+
*
|
|
293
|
+
* ⚠️ **必须持 `.quarantine` 锁调用**(见 quarantineTransaction)。它是
|
|
294
|
+
* `mkdir + rename + unlink + 写元数据` 的多步变更,无锁并发会各建一个隔离目录。
|
|
295
|
+
*/
|
|
296
|
+
async function quarantineTransactionLocked(base: string, reason: string): Promise<void> {
|
|
297
|
+
const manifestPath = transactionManifestPath(base);
|
|
298
|
+
const manifestRaw = await readFile(manifestPath, 'utf8').catch(() => null);
|
|
299
|
+
// 拿到锁后清单已经不在 = 另一个读者/进程刚刚隔离过同一份。直接返回:
|
|
300
|
+
// 不重复建目录、不重复告警。这一条取代了以前那个只覆盖 ENOENT 交错的特判。
|
|
301
|
+
if (manifestRaw === null) return;
|
|
302
|
+
|
|
303
|
+
const target = join(
|
|
304
|
+
transactionRoot(base),
|
|
305
|
+
`quarantine-${Date.now()}-${randomUUID().slice(0, 8)}`,
|
|
306
|
+
);
|
|
307
|
+
await mkdir(target, { recursive: true, mode: 0o700 });
|
|
308
|
+
let claimed = true;
|
|
309
|
+
try {
|
|
310
|
+
await rename(manifestPath, join(target, 'current.json'));
|
|
311
|
+
} catch {
|
|
312
|
+
// 跨卷或某些网络盘上 rename 可能失败。必须先保存并校验副本,再移除原清单;
|
|
313
|
+
// 不能为了让读路径继续就丢掉唯一的恢复证据。
|
|
314
|
+
const preserved = join(target, 'current.json');
|
|
315
|
+
await atomicWriteFile(preserved, manifestRaw).catch(() => undefined);
|
|
316
|
+
const verified = await readFile(preserved, 'utf8')
|
|
317
|
+
.then((value) => sha256(value) === sha256(manifestRaw))
|
|
318
|
+
.catch(() => false);
|
|
319
|
+
claimed = verified
|
|
320
|
+
? await unlink(manifestPath).then(() => true).catch(() => false)
|
|
321
|
+
: false;
|
|
322
|
+
}
|
|
323
|
+
if (!claimed) {
|
|
324
|
+
// 证据没保住就别留下空目录——它会被 quarantineDiagnosticsFor 当成一次真实隔离,
|
|
325
|
+
// 报出「已隔离到 XXX」却什么都恢复不了。
|
|
326
|
+
await rm(target, { recursive: true, force: true }).catch(() => undefined);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
const metadata: QuarantineMetadata = {
|
|
330
|
+
schemaVersion: 1,
|
|
331
|
+
quarantinedAt: Date.now(),
|
|
332
|
+
reason,
|
|
333
|
+
originalManifestPath: manifestPath,
|
|
334
|
+
};
|
|
335
|
+
await atomicWriteFile(join(target, 'diagnostic.json'), `${JSON.stringify(metadata, null, 2)}\n`)
|
|
336
|
+
.catch(() => undefined);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* 加锁版入口。用**独立**的 `.quarantine` 锁而不是 `.records`:
|
|
341
|
+
* 需要互斥的只有「另一个隔离尝试」——合法的在飞事务其清单由 atomicWriteFile 写出,
|
|
342
|
+
* 永远是完整可解析的,不会被误隔离,所以无需与记录写入互斥。
|
|
343
|
+
* 而且 `memoryService.remember()` 是**持着 `.records` 锁**调用 scanMemoryScope 的,
|
|
344
|
+
* 这里若复用 `.records` 会自锁(文件锁不可重入)直接等到超时。
|
|
345
|
+
*/
|
|
346
|
+
async function quarantineTransaction(base: string, reason: string): Promise<void> {
|
|
347
|
+
const release = await acquireFileLock(quarantineLockPath(base));
|
|
348
|
+
try {
|
|
349
|
+
await quarantineTransactionLocked(base, reason);
|
|
350
|
+
} finally {
|
|
351
|
+
await release();
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function recoverScopeTransaction(base: string): Promise<ScopeRecovery> {
|
|
356
|
+
let manifestExists: boolean;
|
|
357
|
+
try {
|
|
358
|
+
manifestExists = (await readTransactionManifest(base)) !== null;
|
|
359
|
+
} catch (error) {
|
|
360
|
+
const reason = (error as Error).message;
|
|
361
|
+
if (!TRANSACTION_POISON_ERRORS.has(reason)) throw error;
|
|
362
|
+
// 清单读不出来(结构损坏 / JSON 语法错 / 路径越界)→ 隔离。
|
|
363
|
+
// ⚠️ 这里曾写着「锁都不必拿,直接隔离」并真的裸调 —— 那是错的:隔离是多步文件变更,
|
|
364
|
+
// 两个并发读者(典型触发者:一次 remember() 后台刷新视图的扫描 + 用户这次读)
|
|
365
|
+
// 会各建一个隔离目录,其中一个还留不下 current.json,却照样报「已隔离到 XXX」。
|
|
366
|
+
// 同一函数在下面 roll-forward 失败处本来就是持锁调用的,这个不对称就是缺陷本身。
|
|
367
|
+
await quarantineTransaction(base, reason);
|
|
368
|
+
return { recovered: true };
|
|
369
|
+
}
|
|
370
|
+
if (!manifestExists) return { recovered: false };
|
|
143
371
|
const release = await acquireFileLock(join(base, '.records'));
|
|
144
372
|
try {
|
|
145
373
|
await rollForwardTransactionUnlocked(base);
|
|
374
|
+
} catch (error) {
|
|
375
|
+
const reason = (error as Error).message;
|
|
376
|
+
if (!TRANSACTION_POISON_ERRORS.has(reason)) throw error;
|
|
377
|
+
await quarantineTransaction(base, reason);
|
|
378
|
+
return { recovered: true };
|
|
146
379
|
} finally {
|
|
147
380
|
await release();
|
|
148
381
|
}
|
|
149
|
-
return true;
|
|
382
|
+
return { recovered: true };
|
|
150
383
|
}
|
|
151
384
|
|
|
152
385
|
function scopeKey(scope: MemoryScope, workspaceId?: string): string {
|
|
153
386
|
return scopeDirectory(scope, workspaceId);
|
|
154
387
|
}
|
|
155
388
|
|
|
156
|
-
|
|
157
|
-
|
|
389
|
+
/**
|
|
390
|
+
* 重建 `views/topics.md`(人类可读视图,不是权威数据)。
|
|
391
|
+
*
|
|
392
|
+
* 只在两种时刻调用:**全量扫描之后**,以及**记忆变更之后**。读路径不再触发它——
|
|
393
|
+
* 否则每次 recall / 建上下文包都要 readFile 一次,连「缓存命中」都带磁盘 IO,
|
|
394
|
+
* 而且 read-only 会话也会写盘。
|
|
395
|
+
*/
|
|
396
|
+
export async function refreshTopicView(
|
|
158
397
|
scope: MemoryScope,
|
|
159
398
|
workspaceId?: string,
|
|
160
|
-
): Promise<
|
|
161
|
-
await
|
|
162
|
-
|
|
399
|
+
): Promise<void> {
|
|
400
|
+
const snapshot = await scanMemoryScope(scope, workspaceId);
|
|
401
|
+
await ensureTopicView(snapshot, scope, workspaceId);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function currentEpoch(key: string): number {
|
|
405
|
+
return scopeState(key).epoch;
|
|
163
406
|
}
|
|
164
407
|
|
|
165
408
|
function invalidateScope(scope: MemoryScope, workspaceId?: string): void {
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
409
|
+
const key = scopeKey(scope, workspaceId);
|
|
410
|
+
const state = scopeState(key);
|
|
411
|
+
state.epoch += 1;
|
|
412
|
+
if (state.cache && !state.cache.dirty) {
|
|
413
|
+
state.cache.dirty = true;
|
|
169
414
|
repositoryDiagnostics.watchInvalidations += 1;
|
|
170
415
|
}
|
|
171
416
|
}
|
|
172
417
|
|
|
418
|
+
function closeScopeWatchers(state: ScopeRuntimeState): void {
|
|
419
|
+
for (const watcher of state.watchers) watcher.close();
|
|
420
|
+
state.watchers = [];
|
|
421
|
+
state.watcherComplete = false;
|
|
422
|
+
}
|
|
423
|
+
|
|
173
424
|
function ensureScopeWatcher(
|
|
174
425
|
base: string,
|
|
175
426
|
scope: MemoryScope,
|
|
176
427
|
workspaceId?: string,
|
|
177
428
|
): boolean {
|
|
178
429
|
const key = scopeKey(scope, workspaceId);
|
|
179
|
-
|
|
430
|
+
const state = scopeState(key);
|
|
431
|
+
if (state.watchers.length && !state.watcherRefreshRequired) return state.watcherComplete;
|
|
432
|
+
closeScopeWatchers(state);
|
|
433
|
+
state.watcherRefreshRequired = false;
|
|
180
434
|
try {
|
|
181
435
|
const watchers: FSWatcher[] = [];
|
|
182
|
-
const
|
|
183
|
-
|
|
436
|
+
const rootWatcher = watch(base, (event, filename) => {
|
|
437
|
+
if (typeof filename !== 'string') {
|
|
438
|
+
// 根目录的无名事件无法判定来源,不直接打脏;让下次读立即做轻量审计。
|
|
439
|
+
if (state.cache) state.cache.lastAuditAt = 0;
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
if (GENERATED_SCOPE_ENTRIES.has(filename)) return;
|
|
443
|
+
if (event !== 'rename' || !CATEGORY_SCOPE_ENTRIES.has(filename)) return;
|
|
444
|
+
state.watcherRefreshRequired = true;
|
|
445
|
+
invalidateScope(scope, workspaceId);
|
|
446
|
+
});
|
|
447
|
+
rootWatcher.unref();
|
|
448
|
+
watchers.push(rootWatcher);
|
|
449
|
+
|
|
450
|
+
const watchCategory = (path: string): void => {
|
|
451
|
+
const watcher = watch(path, (_event, filename) => {
|
|
452
|
+
if (typeof filename === 'string' && !filename.endsWith('.md')) return;
|
|
453
|
+
if (typeof filename === 'string' && consumeInternalRecordEvent(join(path, filename))) return;
|
|
454
|
+
// 全量扫描刚结束时,部分平台会延迟送达「已被该快照包含」的旧事件。
|
|
455
|
+
// 这个短窗口内不盲目打脏,而是强制下次读做版本/目录审计;因此真实外部
|
|
456
|
+
// 修改仍会被发现,而迟到的旧事件不会导致无意义全量重扫。
|
|
457
|
+
if (Date.now() < state.suppressDirtyUntil) {
|
|
458
|
+
if (state.cache) state.cache.lastAuditAt = 0;
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
invalidateScope(scope, workspaceId);
|
|
462
|
+
});
|
|
184
463
|
watcher.unref();
|
|
185
464
|
watchers.push(watcher);
|
|
186
465
|
};
|
|
187
|
-
|
|
466
|
+
// ★ 覆盖必须**完整**才敢自称 watcherActive,否则读路径不能享受 1 秒信任窗(见下)。
|
|
467
|
+
// 实测(Windows,`fs.watch` 非递归):手工编辑 `<base>/profile/x.md` 只触发**分类目录**
|
|
468
|
+
// 上的 watcher(`change x.md`),base 上的 watcher 一声不吭——所以分类目录的 watcher
|
|
469
|
+
// 是「改动能不能被发现」的唯一依据。
|
|
470
|
+
// ⚠️ 「不存在的分类目录」不算覆盖缺口:它装不下任何记录,而且它**被创建**时 base 上的
|
|
471
|
+
// watcher 会收到 `rename <category>`(同样实测过),照样能作废缓存。只有目录确实存在
|
|
472
|
+
// 却挂不上 watcher(句柄耗尽、权限、网络盘…)才是真缺口,那时必须每次读都做审计。
|
|
473
|
+
let complete = true;
|
|
188
474
|
for (const category of ALL_CATEGORIES) {
|
|
189
|
-
try {
|
|
475
|
+
try {
|
|
476
|
+
watchCategory(join(base, CATEGORY_DIR[category]));
|
|
477
|
+
} catch (error) {
|
|
478
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') complete = false;
|
|
479
|
+
}
|
|
190
480
|
}
|
|
191
481
|
const fallback = () => {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
if (cached) watcherActiveFallback(cached);
|
|
482
|
+
closeScopeWatchers(state);
|
|
483
|
+
state.watcherRefreshRequired = true;
|
|
484
|
+
if (state.cache) watcherActiveFallback(state.cache);
|
|
196
485
|
};
|
|
197
486
|
for (const watcher of watchers) watcher.on('error', fallback);
|
|
198
|
-
|
|
199
|
-
|
|
487
|
+
state.watchers = watchers;
|
|
488
|
+
state.watcherComplete = complete;
|
|
489
|
+
return complete;
|
|
200
490
|
} catch {
|
|
491
|
+
closeScopeWatchers(state);
|
|
201
492
|
return false;
|
|
202
493
|
}
|
|
203
494
|
}
|
|
204
495
|
|
|
205
|
-
function watcherActiveFallback(cached:
|
|
206
|
-
cached.watcherActive = false;
|
|
496
|
+
function watcherActiveFallback(cached: ScopeCacheEntry): void {
|
|
207
497
|
cached.dirty = true;
|
|
208
498
|
}
|
|
209
499
|
|
|
210
500
|
function evictScopeCaches(): void {
|
|
211
|
-
if (
|
|
212
|
-
const victims = [...
|
|
501
|
+
if (scopeStates.size <= MAX_SCOPE_CACHE_ENTRIES) return;
|
|
502
|
+
const victims = [...scopeStates.entries()]
|
|
213
503
|
.sort((a, b) => a[1].lastUsedAt - b[1].lastUsedAt)
|
|
214
|
-
.slice(0,
|
|
504
|
+
.slice(0, scopeStates.size - MAX_SCOPE_CACHE_ENTRIES);
|
|
215
505
|
for (const [key] of victims) {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
506
|
+
const state = scopeStates.get(key);
|
|
507
|
+
if (state) closeScopeWatchers(state);
|
|
508
|
+
scopeStates.delete(key);
|
|
509
|
+
// ⚠️ 不能写 `path.startsWith(`${key}/`)`:Windows 上分隔符是 `\`,该条件恒为 false,
|
|
510
|
+
// 被淘汰 scope 的文件缓存永远清不掉 → fileCache 无上限增长。
|
|
219
511
|
for (const path of fileCache.keys()) {
|
|
220
|
-
if (
|
|
512
|
+
if (pathContains(key, path)) fileCache.delete(path);
|
|
221
513
|
}
|
|
222
514
|
}
|
|
223
515
|
}
|
|
224
516
|
|
|
517
|
+
async function settleRecentWatcherEvents(state: ScopeRuntimeState): Promise<boolean> {
|
|
518
|
+
await new Promise<void>((resolvePromise) => { setImmediate(resolvePromise); });
|
|
519
|
+
if (state.cache?.dirty) return false;
|
|
520
|
+
const now = Date.now();
|
|
521
|
+
if (now - state.lastDrainAt >= WATCHER_SETTLE_REUSE_MS) {
|
|
522
|
+
await new Promise<void>((resolvePromise) => {
|
|
523
|
+
setTimeout(resolvePromise, WATCHER_SETTLE_WAIT_MS);
|
|
524
|
+
});
|
|
525
|
+
state.lastDrainAt = Date.now();
|
|
526
|
+
}
|
|
527
|
+
// 正常 watcher 在上面的 settle 窗口中会直接打脏;扫描结束后的抑制窗口只把
|
|
528
|
+
// lastAuditAt 清零,故仅这一种情况需要逐文件核验。稳定缓存不做 stat 扫描。
|
|
529
|
+
return state.cache?.lastAuditAt === 0 || Date.now() < state.suppressDirtyUntil;
|
|
530
|
+
}
|
|
531
|
+
|
|
225
532
|
async function cachedFilesUnchanged(snapshot: MemorySnapshot): Promise<boolean> {
|
|
226
533
|
const checks = await Promise.all(snapshot.records.map(async (record) => {
|
|
227
534
|
const path = memoryRecordPath(record);
|
|
@@ -282,50 +589,82 @@ async function readCached(path: string): Promise<CachedFile> {
|
|
|
282
589
|
export async function scanMemoryScope(
|
|
283
590
|
scope: MemoryScope,
|
|
284
591
|
workspaceId?: string,
|
|
592
|
+
attempt = 0,
|
|
285
593
|
): Promise<MemorySnapshot> {
|
|
286
594
|
const base = scopeDirectory(scope, workspaceId);
|
|
287
595
|
const key = scopeKey(scope, workspaceId);
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
596
|
+
const state = scopeState(key);
|
|
597
|
+
const recovery = await recoverScopeTransaction(base);
|
|
598
|
+
let cachedScope = state.cache;
|
|
599
|
+
if (recovery.recovered && cachedScope) cachedScope.dirty = true;
|
|
291
600
|
const generationBefore = await readGeneration(base);
|
|
292
601
|
const now = Date.now();
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
602
|
+
// ★ 读路径保持纯读:缓存命中直接返回快照,不再顺手 readFile / 写 views/topics.md。
|
|
603
|
+
// 旧实现把 ensureTopicView 挂在**所有**返回分支上,于是「缓存命中」仍有磁盘 IO,
|
|
604
|
+
// 而且 memoryAccess:'read-only' 的会话也会写盘——仓储的读 API 不该有副作用。
|
|
605
|
+
// 视图改由「全量扫描后」与「变更后」触发(见 refreshTopicView)。
|
|
606
|
+
// ★ 新鲜度两条腿,缺一不可:
|
|
607
|
+
//
|
|
608
|
+
// ① **排空一次事件循环**再判 dirty。`fs.watch` 的回调是排队派发的,「watcher 已挂上」
|
|
609
|
+
// 不等于「它已经把刚才那次改动告诉我了」。实测(Windows):改完文件后只要让出
|
|
610
|
+
// 一个 setImmediate tick,事件就已送达。这一步零 IO,却把「刚改完立刻读」这个
|
|
611
|
+
// 最常见的竞态消掉了。旧代码也想干这事,但把它锁在「距上次使用 <50ms」的条件里,
|
|
612
|
+
// 两次读之间只要多做点别的(哪怕只是写一次设置)就绕过去了。
|
|
613
|
+
//
|
|
614
|
+
// ② watcher 覆盖不全或压根没触发时,用 stat 审计兜底,节流到每秒最多一次。
|
|
615
|
+
// 审计是 O(记录数) 次 stat——实测 300 条记录约 40ms,放在每次读上太贵
|
|
616
|
+
// (每回合 recall + buildContextBundle 各一次),所以必须节流;但 30 秒的信任窗
|
|
617
|
+
// 又太松,手编要等半分钟才生效。1 秒是两边都能接受的点。
|
|
618
|
+
//
|
|
619
|
+
// ⚠️ 别再让读路径去写 `views/topics.md` 来「顺便」作废缓存——那是以前手编能被发现的
|
|
620
|
+
// 真实原因,纯属巧合,代价是每次读都做磁盘 IO。
|
|
621
|
+
let verifyAfterSettle = false;
|
|
304
622
|
if (cachedScope && !cachedScope.dirty) {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
623
|
+
verifyAfterSettle = await settleRecentWatcherEvents(state);
|
|
624
|
+
cachedScope = state.cache;
|
|
625
|
+
}
|
|
626
|
+
if (cachedScope && !cachedScope.dirty && verifyAfterSettle) {
|
|
627
|
+
repositoryDiagnostics.fallbackAudits += 1;
|
|
628
|
+
if (
|
|
629
|
+
!await cachedFilesUnchanged(cachedScope.snapshot)
|
|
630
|
+
|| cachedScope.directoryFingerprint !== await directoryFingerprint(base)
|
|
631
|
+
) {
|
|
632
|
+
cachedScope.dirty = true;
|
|
633
|
+
} else {
|
|
634
|
+
cachedScope.lastAuditAt = Date.now();
|
|
309
635
|
}
|
|
310
|
-
|
|
636
|
+
}
|
|
637
|
+
if (cachedScope && !cachedScope.dirty) {
|
|
638
|
+
state.lastUsedAt = now;
|
|
639
|
+
// 只有 watcher 覆盖完整时才敢跳过审计——覆盖有缺口就等于没人会告诉我们变更,
|
|
640
|
+
// 那个 1 秒信任窗就失去依据,必须每次读都审计。
|
|
641
|
+
if (state.watcherComplete && now - cachedScope.lastAuditAt < SCOPE_AUDIT_TTL_MS) {
|
|
311
642
|
repositoryDiagnostics.snapshotHits += 1;
|
|
312
|
-
return
|
|
643
|
+
return cachedScope.snapshot;
|
|
313
644
|
}
|
|
314
645
|
repositoryDiagnostics.fallbackAudits += 1;
|
|
315
646
|
if (
|
|
316
647
|
await cachedFilesUnchanged(cachedScope.snapshot)
|
|
317
648
|
&& cachedScope.directoryFingerprint === await directoryFingerprint(base)
|
|
318
649
|
) {
|
|
319
|
-
cachedScope.checkedAt = now;
|
|
320
650
|
cachedScope.lastAuditAt = now;
|
|
321
651
|
repositoryDiagnostics.snapshotHits += 1;
|
|
322
|
-
return
|
|
652
|
+
return cachedScope.snapshot;
|
|
323
653
|
}
|
|
324
654
|
cachedScope.dirty = true;
|
|
325
655
|
}
|
|
326
656
|
repositoryDiagnostics.fullScans += 1;
|
|
657
|
+
// ★ 先挂 watcher 再读文件,并记下代际号。
|
|
658
|
+
// 旧顺序是「先把记录全读完,最后才挂 watcher」,中间那段窗口里发生的改动**两头都漏**:
|
|
659
|
+
// 读的时候还没改,watcher 又还没挂上——扫完却把这份已经过时的快照标记为干净缓存,
|
|
660
|
+
// 于是直到下一次兜底审计(现在是 1 秒)之前,谁都发现不了。这是原设计里的 TOCTOU。
|
|
661
|
+
// 现在:先挂 watcher,扫完再比对代际号,期间有任何改动就重扫。
|
|
662
|
+
const watcherActive = ensureScopeWatcher(base, scope, workspaceId);
|
|
663
|
+
const epochBefore = currentEpoch(key);
|
|
327
664
|
const records: MemoryRecordV2[] = [];
|
|
328
|
-
|
|
665
|
+
// 隔离事务留下的告警要跟着本次快照走完,不能只写日志然后消失——
|
|
666
|
+
// diagnostics 是既有的、Desktop 与 diagnosticSuffix() 都会展示的通道。
|
|
667
|
+
const diagnostics: MemoryDiagnostic[] = await quarantineDiagnosticsFor(base);
|
|
329
668
|
const versions: Record<string, MemoryVersion> = {};
|
|
330
669
|
const seen = new Set<string>();
|
|
331
670
|
for (const category of ALL_CATEGORIES) {
|
|
@@ -369,35 +708,45 @@ export async function scanMemoryScope(
|
|
|
369
708
|
}
|
|
370
709
|
}
|
|
371
710
|
|
|
711
|
+
// 同上:硬编码 `/` 在 Windows 上永不命中,已删除记录的缓存条目会永久滞留。
|
|
372
712
|
for (const path of fileCache.keys()) {
|
|
373
|
-
if (
|
|
374
|
-
path.startsWith(`${base}/`) &&
|
|
375
|
-
path.endsWith('.md') &&
|
|
376
|
-
!seen.has(path)
|
|
377
|
-
) {
|
|
713
|
+
if (path.endsWith('.md') && !seen.has(path) && pathContains(base, path)) {
|
|
378
714
|
fileCache.delete(path);
|
|
379
715
|
}
|
|
380
716
|
}
|
|
381
717
|
records.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
382
718
|
const snapshot = { records, versions, diagnostics, createdAt: Date.now() };
|
|
383
|
-
|
|
719
|
+
const tailRecovery = await recoverScopeTransaction(base);
|
|
720
|
+
if (
|
|
721
|
+
(tailRecovery.recovered || generationBefore !== await readGeneration(base))
|
|
722
|
+
&& attempt < MAX_SCAN_RETRIES
|
|
723
|
+
) {
|
|
384
724
|
invalidateScope(scope, workspaceId);
|
|
385
|
-
return scanMemoryScope(scope, workspaceId);
|
|
725
|
+
return scanMemoryScope(scope, workspaceId, attempt + 1);
|
|
386
726
|
}
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
727
|
+
// 让排队中的 watcher 回调先送达,再判断这次扫描期间有没有人改过东西。
|
|
728
|
+
await new Promise<void>((resolvePromise) => { setImmediate(resolvePromise); });
|
|
729
|
+
// ⚠️ 重试必须有上限:持续写入的场景(cron 批量、另一个宿主导入、迁移)下每次重扫都会
|
|
730
|
+
// 看到新的代际号,无上限就是**活锁——读永远不返回**(还每轮重开 7 个 watcher)。
|
|
731
|
+
// 撞顶就照常缓存,让 1 秒兜底审计去纠正:最坏是几毫秒的陈旧快照且会自愈,
|
|
732
|
+
// 远好过一次挂死的 recall。
|
|
733
|
+
const changedDuringScan = currentEpoch(key) !== epochBefore;
|
|
734
|
+
if (changedDuringScan && attempt < MAX_SCAN_RETRIES) {
|
|
735
|
+
return scanMemoryScope(scope, workspaceId, attempt + 1);
|
|
736
|
+
}
|
|
737
|
+
state.cache = {
|
|
391
738
|
snapshot,
|
|
392
|
-
checkedAt: Date.now(),
|
|
393
739
|
dirty: false,
|
|
394
|
-
watcherActive,
|
|
395
740
|
directoryFingerprint: await directoryFingerprint(base),
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
741
|
+
// 撞了重试上限才落库的快照是**已知可能陈旧**的,不能再送它一个完整的 1 秒信任窗——
|
|
742
|
+
// 把审计时间戳清零,下一次读立刻走 stat 审计纠正。
|
|
743
|
+
lastAuditAt: changedDuringScan ? 0 : Date.now(),
|
|
744
|
+
};
|
|
745
|
+
state.watcherComplete = watcherActive;
|
|
746
|
+
state.lastUsedAt = Date.now();
|
|
747
|
+
state.suppressDirtyUntil = Date.now() + 100;
|
|
399
748
|
evictScopeCaches();
|
|
400
|
-
return
|
|
749
|
+
return snapshot;
|
|
401
750
|
}
|
|
402
751
|
|
|
403
752
|
export async function readVersionedMemory(
|
|
@@ -415,6 +764,7 @@ export async function writeNewMemory(
|
|
|
415
764
|
): Promise<MemoryVersion> {
|
|
416
765
|
const path = memoryRecordPath(record);
|
|
417
766
|
const content = serializeMemoryRecord(record);
|
|
767
|
+
markInternalRecordEvent(path);
|
|
418
768
|
await atomicWriteFile(path, content);
|
|
419
769
|
fileCache.delete(path);
|
|
420
770
|
invalidateScope(record.scope, record.workspaceId);
|
|
@@ -521,6 +871,7 @@ export async function removeVersionedMemory(
|
|
|
521
871
|
throw new Error('MEMORY_VERSION_CONFLICT');
|
|
522
872
|
}
|
|
523
873
|
try {
|
|
874
|
+
markInternalRecordEvent(path);
|
|
524
875
|
await unlink(path);
|
|
525
876
|
fileCache.delete(path);
|
|
526
877
|
invalidateScope(current.record.scope, current.record.workspaceId);
|
|
@@ -536,7 +887,7 @@ export async function removeVersionedMemory(
|
|
|
536
887
|
|
|
537
888
|
export function clearMemoryIndex(): void {
|
|
538
889
|
fileCache.clear();
|
|
539
|
-
|
|
540
|
-
for (const
|
|
541
|
-
|
|
890
|
+
internalRecordEvents.clear();
|
|
891
|
+
for (const state of scopeStates.values()) closeScopeWatchers(state);
|
|
892
|
+
scopeStates.clear();
|
|
542
893
|
}
|