minimal-agent 0.11.0 → 0.12.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 +4 -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/index.js +5 -3
- package/plugins/workflow-runner/src/index.ts +31 -13
- package/plugins/workflow-runner/src/multimodal.js +2 -1
- package/plugins/workflow-runner/src/multimodal.ts +2 -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 +68 -57
- package/plugins/workflow-runner/src/stepExecutors/tool.ts +88 -66
- package/plugins/workflow-runner/src/toolRuntime.js +48 -0
- package/plugins/workflow-runner/src/toolRuntime.ts +64 -0
- package/plugins/workflow-runner/src/types.ts +26 -7
- package/plugins/workflow-runner/test/context-files.test.ts +47 -16
- package/plugins/workflow-runner/test/hostCapability.test.ts +9 -3
- package/plugins/workflow-runner/test/multimodal.test.ts +61 -10
- package/plugins/workflow-runner/test/toolRuntimeIntegration.test.ts +168 -0
- package/src/cli/print.js +19 -4
- package/src/cli/print.ts +17 -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/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/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 +6 -2
- package/src/plugins/pluginRunner.ts +12 -7
- package/src/prompts/policyRegistry.js +27 -0
- package/src/prompts/policyRegistry.ts +27 -0
- 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/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 +31 -11
- package/src/tools/bash/bash.ts +29 -10
- 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 +116 -26
- package/src/tools/powershell/powershell.ts +155 -30
- package/src/tools/presentresult/presentresult.js +10 -0
- package/src/tools/presentresult/presentresult.ts +10 -0
- package/src/tools/read/read.js +244 -29
- package/src/tools/read/read.ts +391 -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 +26 -0
- package/src/tools/shared/permanentDeleteGuard.ts +34 -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/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 +12 -0
- package/src/ui/hooks/useChat.js +31 -11
- package/src/ui/hooks/useChat.ts +31 -13
- package/src/web/protocol.ts +78 -3
package/src/memory/repository.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { watch } 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
|
+
import { pathContains } from '../utils/pathContains.js';
|
|
5
6
|
import { parseMemoryRecord, serializeMemoryRecord, sha256 } from './codec.js';
|
|
6
7
|
import { ALL_CATEGORIES, CATEGORY_DIR } from './constants.js';
|
|
7
8
|
import { acquireFileLock, atomicWriteFile, memoryRecordPath, scopeDirectory, } from './fileStore.js';
|
|
8
9
|
import { ensureTopicView } from './topicView.js';
|
|
9
10
|
const fileCache = new Map();
|
|
10
|
-
|
|
11
|
-
const
|
|
11
|
+
/** 兜底审计的最小间隔:watcher 漏掉的变更最迟这么久后会被 stat 审计发现。 */
|
|
12
|
+
const SCOPE_AUDIT_TTL_MS = 1_000;
|
|
13
|
+
/** 「扫描期间被改动 → 重扫」的重试上限,防止持续写入把读拖成活锁。 */
|
|
14
|
+
const MAX_SCAN_RETRIES = 3;
|
|
12
15
|
const MAX_SCOPE_CACHE_ENTRIES = 128;
|
|
13
16
|
const repositoryDiagnostics = {
|
|
14
17
|
snapshotHits: 0,
|
|
@@ -25,14 +28,58 @@ export function resetMemoryRepositoryDiagnostics() {
|
|
|
25
28
|
repositoryDiagnostics.fallbackAudits = 0;
|
|
26
29
|
repositoryDiagnostics.fullScans = 0;
|
|
27
30
|
}
|
|
28
|
-
const
|
|
29
|
-
|
|
31
|
+
const scopeStates = new Map();
|
|
32
|
+
function scopeState(key) {
|
|
33
|
+
let state = scopeStates.get(key);
|
|
34
|
+
if (!state) {
|
|
35
|
+
state = {
|
|
36
|
+
watchers: [],
|
|
37
|
+
watcherComplete: false,
|
|
38
|
+
watcherRefreshRequired: false,
|
|
39
|
+
epoch: 0,
|
|
40
|
+
lastUsedAt: 0,
|
|
41
|
+
lastDrainAt: 0,
|
|
42
|
+
suppressDirtyUntil: 0,
|
|
43
|
+
};
|
|
44
|
+
scopeStates.set(key, state);
|
|
45
|
+
}
|
|
46
|
+
return state;
|
|
47
|
+
}
|
|
48
|
+
/** scope 目录下由本模块自己生成、不该被当成「用户改了记忆」的条目。 */
|
|
49
|
+
const GENERATED_SCOPE_ENTRIES = new Set([
|
|
50
|
+
'views',
|
|
51
|
+
'.transactions',
|
|
52
|
+
'.records.lock',
|
|
53
|
+
]);
|
|
54
|
+
const CATEGORY_SCOPE_ENTRIES = new Set(Object.values(CATEGORY_DIR));
|
|
55
|
+
const WATCHER_SETTLE_REUSE_MS = 50;
|
|
56
|
+
const WATCHER_SETTLE_WAIT_MS = 20;
|
|
57
|
+
const INTERNAL_RECORD_EVENT_TTL_MS = 1_000;
|
|
58
|
+
const internalRecordEvents = new Map();
|
|
59
|
+
function markInternalRecordEvent(path) {
|
|
60
|
+
internalRecordEvents.set(path, Date.now() + INTERNAL_RECORD_EVENT_TTL_MS);
|
|
61
|
+
}
|
|
62
|
+
function consumeInternalRecordEvent(path) {
|
|
63
|
+
const expiresAt = internalRecordEvents.get(path);
|
|
64
|
+
if (!expiresAt)
|
|
65
|
+
return false;
|
|
66
|
+
if (expiresAt < Date.now()) {
|
|
67
|
+
internalRecordEvents.delete(path);
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
internalRecordEvents.delete(path);
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
30
73
|
function transactionRoot(base) { return join(base, '.transactions'); }
|
|
31
74
|
function transactionManifestPath(base) { return join(transactionRoot(base), 'current.json'); }
|
|
32
75
|
function generationPath(base) { return join(transactionRoot(base), 'generation'); }
|
|
76
|
+
/**
|
|
77
|
+
* 「这个路径在不在那个目录里」只有一份实现:`src/utils/pathContains.ts`。
|
|
78
|
+
* 这里只负责把两边解析成绝对路径(manifest 里的路径来自 JSON,可能是相对的),
|
|
79
|
+
* 判据本身不再手写第五遍——那个 util 的头注释专门警告过手写副本会各自漂移。
|
|
80
|
+
*/
|
|
33
81
|
function pathInside(base, path) {
|
|
34
|
-
|
|
35
|
-
return rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
82
|
+
return pathContains(resolve(base), resolve(path));
|
|
36
83
|
}
|
|
37
84
|
async function readGeneration(base) {
|
|
38
85
|
return readFile(generationPath(base), 'utf8').catch(() => '');
|
|
@@ -45,7 +92,13 @@ async function readTransactionManifest(base) {
|
|
|
45
92
|
});
|
|
46
93
|
if (raw === null)
|
|
47
94
|
return null;
|
|
48
|
-
|
|
95
|
+
let parsed;
|
|
96
|
+
try {
|
|
97
|
+
parsed = JSON.parse(raw);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
throw new Error('MEMORY_TRANSACTION_CORRUPT');
|
|
101
|
+
}
|
|
49
102
|
if (parsed.schemaVersion !== 1 || !parsed.id || !Array.isArray(parsed.items)) {
|
|
50
103
|
throw new Error('MEMORY_TRANSACTION_CORRUPT');
|
|
51
104
|
}
|
|
@@ -75,6 +128,9 @@ async function rollForwardTransactionUnlocked(base) {
|
|
|
75
128
|
throw new Error('MEMORY_TRANSACTION_CONFLICT');
|
|
76
129
|
}
|
|
77
130
|
await mkdir(dirname(item.nextPath), { recursive: true, mode: 0o700 });
|
|
131
|
+
markInternalRecordEvent(item.nextPath);
|
|
132
|
+
if (item.oldPath !== item.nextPath)
|
|
133
|
+
markInternalRecordEvent(item.oldPath);
|
|
78
134
|
await rename(item.stagePath, item.nextPath);
|
|
79
135
|
if (item.oldPath !== item.nextPath)
|
|
80
136
|
await unlink(item.oldPath).catch(() => { });
|
|
@@ -83,89 +139,321 @@ async function rollForwardTransactionUnlocked(base) {
|
|
|
83
139
|
await unlink(transactionManifestPath(base)).catch(() => { });
|
|
84
140
|
await rm(join(transactionRoot(base), manifest.id), { recursive: true, force: true });
|
|
85
141
|
}
|
|
142
|
+
const TRANSACTION_POISON_ERRORS = new Set([
|
|
143
|
+
'MEMORY_TRANSACTION_CORRUPT',
|
|
144
|
+
'MEMORY_TRANSACTION_PATH_INVALID',
|
|
145
|
+
'MEMORY_TRANSACTION_CONFLICT',
|
|
146
|
+
]);
|
|
147
|
+
async function quarantineDiagnosticsFor(base) {
|
|
148
|
+
const root = transactionRoot(base);
|
|
149
|
+
let names;
|
|
150
|
+
try {
|
|
151
|
+
names = (await readdir(root, { withFileTypes: true }))
|
|
152
|
+
.filter((entry) => entry.isDirectory() && entry.name.startsWith('quarantine-'))
|
|
153
|
+
.map((entry) => entry.name);
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
if (error.code === 'ENOENT')
|
|
157
|
+
return [];
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
const diagnostics = [];
|
|
161
|
+
for (const name of names.sort()) {
|
|
162
|
+
const target = join(root, name);
|
|
163
|
+
let metadata = null;
|
|
164
|
+
try {
|
|
165
|
+
metadata = JSON.parse(await readFile(join(target, 'diagnostic.json'), 'utf8'));
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
// 旧版本隔离目录没有元数据时仍需告警,不能因升级让诊断沉默。
|
|
169
|
+
}
|
|
170
|
+
const reason = metadata?.reason ?? 'MEMORY_TRANSACTION_QUARANTINED';
|
|
171
|
+
diagnostics.push({
|
|
172
|
+
path: metadata?.originalManifestPath ?? join(target, 'current.json'),
|
|
173
|
+
message: `记忆事务无法前滚(${reason}),已隔离到 ${target} 并继续读取现有记录;`
|
|
174
|
+
+ '该批次的改动可能只应用了一部分,请复核相关记忆。',
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
return diagnostics;
|
|
178
|
+
}
|
|
179
|
+
export async function clearScopeTransactionQuarantine(scope, workspaceId) {
|
|
180
|
+
const root = transactionRoot(scopeDirectory(scope, workspaceId));
|
|
181
|
+
let names = [];
|
|
182
|
+
try {
|
|
183
|
+
names = (await readdir(root, { withFileTypes: true }))
|
|
184
|
+
.filter((entry) => entry.isDirectory() && entry.name.startsWith('quarantine-'))
|
|
185
|
+
.map((entry) => entry.name);
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
if (error.code !== 'ENOENT')
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
await Promise.all(names.map(async (name) => {
|
|
192
|
+
const quarantine = join(root, name);
|
|
193
|
+
try {
|
|
194
|
+
const manifest = JSON.parse(await readFile(join(quarantine, 'current.json'), 'utf8'));
|
|
195
|
+
if (typeof manifest.id === 'string' && /^[a-f0-9-]{16,}$/i.test(manifest.id)) {
|
|
196
|
+
await rm(join(root, manifest.id), { recursive: true, force: true });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
// 损坏清单本来就可能无法解析;清理隔离目录仍继续。
|
|
201
|
+
}
|
|
202
|
+
await rm(quarantine, { recursive: true, force: true });
|
|
203
|
+
}));
|
|
204
|
+
invalidateScope(scope, workspaceId);
|
|
205
|
+
}
|
|
206
|
+
/** 隔离操作的专用锁路径(实际锁文件是 `<它>.lock`)。 */
|
|
207
|
+
function quarantineLockPath(base) {
|
|
208
|
+
return join(transactionRoot(base), '.quarantine');
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* 把无法前滚的事务清单挪进 `.transactions/quarantine-<ts>/`,让读路径继续可用。
|
|
212
|
+
*
|
|
213
|
+
* ⚠️ 这是**读路径**独有的策略;写路径(replaceMemoryBatchVersioned)仍然照常抛错——
|
|
214
|
+
* 写入方必须看到冲突,不能替它把别人已提交的事务悄悄扔掉。
|
|
215
|
+
*
|
|
216
|
+
* ⚠️ **必须持 `.quarantine` 锁调用**(见 quarantineTransaction)。它是
|
|
217
|
+
* `mkdir + rename + unlink + 写元数据` 的多步变更,无锁并发会各建一个隔离目录。
|
|
218
|
+
*/
|
|
219
|
+
async function quarantineTransactionLocked(base, reason) {
|
|
220
|
+
const manifestPath = transactionManifestPath(base);
|
|
221
|
+
const manifestRaw = await readFile(manifestPath, 'utf8').catch(() => null);
|
|
222
|
+
// 拿到锁后清单已经不在 = 另一个读者/进程刚刚隔离过同一份。直接返回:
|
|
223
|
+
// 不重复建目录、不重复告警。这一条取代了以前那个只覆盖 ENOENT 交错的特判。
|
|
224
|
+
if (manifestRaw === null)
|
|
225
|
+
return;
|
|
226
|
+
const target = join(transactionRoot(base), `quarantine-${Date.now()}-${randomUUID().slice(0, 8)}`);
|
|
227
|
+
await mkdir(target, { recursive: true, mode: 0o700 });
|
|
228
|
+
let claimed = true;
|
|
229
|
+
try {
|
|
230
|
+
await rename(manifestPath, join(target, 'current.json'));
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
// 跨卷或某些网络盘上 rename 可能失败。必须先保存并校验副本,再移除原清单;
|
|
234
|
+
// 不能为了让读路径继续就丢掉唯一的恢复证据。
|
|
235
|
+
const preserved = join(target, 'current.json');
|
|
236
|
+
await atomicWriteFile(preserved, manifestRaw).catch(() => undefined);
|
|
237
|
+
const verified = await readFile(preserved, 'utf8')
|
|
238
|
+
.then((value) => sha256(value) === sha256(manifestRaw))
|
|
239
|
+
.catch(() => false);
|
|
240
|
+
claimed = verified
|
|
241
|
+
? await unlink(manifestPath).then(() => true).catch(() => false)
|
|
242
|
+
: false;
|
|
243
|
+
}
|
|
244
|
+
if (!claimed) {
|
|
245
|
+
// 证据没保住就别留下空目录——它会被 quarantineDiagnosticsFor 当成一次真实隔离,
|
|
246
|
+
// 报出「已隔离到 XXX」却什么都恢复不了。
|
|
247
|
+
await rm(target, { recursive: true, force: true }).catch(() => undefined);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
const metadata = {
|
|
251
|
+
schemaVersion: 1,
|
|
252
|
+
quarantinedAt: Date.now(),
|
|
253
|
+
reason,
|
|
254
|
+
originalManifestPath: manifestPath,
|
|
255
|
+
};
|
|
256
|
+
await atomicWriteFile(join(target, 'diagnostic.json'), `${JSON.stringify(metadata, null, 2)}\n`)
|
|
257
|
+
.catch(() => undefined);
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* 加锁版入口。用**独立**的 `.quarantine` 锁而不是 `.records`:
|
|
261
|
+
* 需要互斥的只有「另一个隔离尝试」——合法的在飞事务其清单由 atomicWriteFile 写出,
|
|
262
|
+
* 永远是完整可解析的,不会被误隔离,所以无需与记录写入互斥。
|
|
263
|
+
* 而且 `memoryService.remember()` 是**持着 `.records` 锁**调用 scanMemoryScope 的,
|
|
264
|
+
* 这里若复用 `.records` 会自锁(文件锁不可重入)直接等到超时。
|
|
265
|
+
*/
|
|
266
|
+
async function quarantineTransaction(base, reason) {
|
|
267
|
+
const release = await acquireFileLock(quarantineLockPath(base));
|
|
268
|
+
try {
|
|
269
|
+
await quarantineTransactionLocked(base, reason);
|
|
270
|
+
}
|
|
271
|
+
finally {
|
|
272
|
+
await release();
|
|
273
|
+
}
|
|
274
|
+
}
|
|
86
275
|
async function recoverScopeTransaction(base) {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
276
|
+
let manifestExists;
|
|
277
|
+
try {
|
|
278
|
+
manifestExists = (await readTransactionManifest(base)) !== null;
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
const reason = error.message;
|
|
282
|
+
if (!TRANSACTION_POISON_ERRORS.has(reason))
|
|
283
|
+
throw error;
|
|
284
|
+
// 清单读不出来(结构损坏 / JSON 语法错 / 路径越界)→ 隔离。
|
|
285
|
+
// ⚠️ 这里曾写着「锁都不必拿,直接隔离」并真的裸调 —— 那是错的:隔离是多步文件变更,
|
|
286
|
+
// 两个并发读者(典型触发者:一次 remember() 后台刷新视图的扫描 + 用户这次读)
|
|
287
|
+
// 会各建一个隔离目录,其中一个还留不下 current.json,却照样报「已隔离到 XXX」。
|
|
288
|
+
// 同一函数在下面 roll-forward 失败处本来就是持锁调用的,这个不对称就是缺陷本身。
|
|
289
|
+
await quarantineTransaction(base, reason);
|
|
290
|
+
return { recovered: true };
|
|
291
|
+
}
|
|
292
|
+
if (!manifestExists)
|
|
293
|
+
return { recovered: false };
|
|
90
294
|
const release = await acquireFileLock(join(base, '.records'));
|
|
91
295
|
try {
|
|
92
296
|
await rollForwardTransactionUnlocked(base);
|
|
93
297
|
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
const reason = error.message;
|
|
300
|
+
if (!TRANSACTION_POISON_ERRORS.has(reason))
|
|
301
|
+
throw error;
|
|
302
|
+
await quarantineTransaction(base, reason);
|
|
303
|
+
return { recovered: true };
|
|
304
|
+
}
|
|
94
305
|
finally {
|
|
95
306
|
await release();
|
|
96
307
|
}
|
|
97
|
-
return true;
|
|
308
|
+
return { recovered: true };
|
|
98
309
|
}
|
|
99
310
|
function scopeKey(scope, workspaceId) {
|
|
100
311
|
return scopeDirectory(scope, workspaceId);
|
|
101
312
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
313
|
+
/**
|
|
314
|
+
* 重建 `views/topics.md`(人类可读视图,不是权威数据)。
|
|
315
|
+
*
|
|
316
|
+
* 只在两种时刻调用:**全量扫描之后**,以及**记忆变更之后**。读路径不再触发它——
|
|
317
|
+
* 否则每次 recall / 建上下文包都要 readFile 一次,连「缓存命中」都带磁盘 IO,
|
|
318
|
+
* 而且 read-only 会话也会写盘。
|
|
319
|
+
*/
|
|
320
|
+
export async function refreshTopicView(scope, workspaceId) {
|
|
321
|
+
const snapshot = await scanMemoryScope(scope, workspaceId);
|
|
322
|
+
await ensureTopicView(snapshot, scope, workspaceId);
|
|
323
|
+
}
|
|
324
|
+
function currentEpoch(key) {
|
|
325
|
+
return scopeState(key).epoch;
|
|
105
326
|
}
|
|
106
327
|
function invalidateScope(scope, workspaceId) {
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
328
|
+
const key = scopeKey(scope, workspaceId);
|
|
329
|
+
const state = scopeState(key);
|
|
330
|
+
state.epoch += 1;
|
|
331
|
+
if (state.cache && !state.cache.dirty) {
|
|
332
|
+
state.cache.dirty = true;
|
|
110
333
|
repositoryDiagnostics.watchInvalidations += 1;
|
|
111
334
|
}
|
|
112
335
|
}
|
|
336
|
+
function closeScopeWatchers(state) {
|
|
337
|
+
for (const watcher of state.watchers)
|
|
338
|
+
watcher.close();
|
|
339
|
+
state.watchers = [];
|
|
340
|
+
state.watcherComplete = false;
|
|
341
|
+
}
|
|
113
342
|
function ensureScopeWatcher(base, scope, workspaceId) {
|
|
114
343
|
const key = scopeKey(scope, workspaceId);
|
|
115
|
-
|
|
116
|
-
|
|
344
|
+
const state = scopeState(key);
|
|
345
|
+
if (state.watchers.length && !state.watcherRefreshRequired)
|
|
346
|
+
return state.watcherComplete;
|
|
347
|
+
closeScopeWatchers(state);
|
|
348
|
+
state.watcherRefreshRequired = false;
|
|
117
349
|
try {
|
|
118
350
|
const watchers = [];
|
|
119
|
-
const
|
|
120
|
-
|
|
351
|
+
const rootWatcher = watch(base, (event, filename) => {
|
|
352
|
+
if (typeof filename !== 'string') {
|
|
353
|
+
// 根目录的无名事件无法判定来源,不直接打脏;让下次读立即做轻量审计。
|
|
354
|
+
if (state.cache)
|
|
355
|
+
state.cache.lastAuditAt = 0;
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (GENERATED_SCOPE_ENTRIES.has(filename))
|
|
359
|
+
return;
|
|
360
|
+
if (event !== 'rename' || !CATEGORY_SCOPE_ENTRIES.has(filename))
|
|
361
|
+
return;
|
|
362
|
+
state.watcherRefreshRequired = true;
|
|
363
|
+
invalidateScope(scope, workspaceId);
|
|
364
|
+
});
|
|
365
|
+
rootWatcher.unref();
|
|
366
|
+
watchers.push(rootWatcher);
|
|
367
|
+
const watchCategory = (path) => {
|
|
368
|
+
const watcher = watch(path, (_event, filename) => {
|
|
369
|
+
if (typeof filename === 'string' && !filename.endsWith('.md'))
|
|
370
|
+
return;
|
|
371
|
+
if (typeof filename === 'string' && consumeInternalRecordEvent(join(path, filename)))
|
|
372
|
+
return;
|
|
373
|
+
// 全量扫描刚结束时,部分平台会延迟送达「已被该快照包含」的旧事件。
|
|
374
|
+
// 这个短窗口内不盲目打脏,而是强制下次读做版本/目录审计;因此真实外部
|
|
375
|
+
// 修改仍会被发现,而迟到的旧事件不会导致无意义全量重扫。
|
|
376
|
+
if (Date.now() < state.suppressDirtyUntil) {
|
|
377
|
+
if (state.cache)
|
|
378
|
+
state.cache.lastAuditAt = 0;
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
invalidateScope(scope, workspaceId);
|
|
382
|
+
});
|
|
121
383
|
watcher.unref();
|
|
122
384
|
watchers.push(watcher);
|
|
123
385
|
};
|
|
124
|
-
|
|
386
|
+
// ★ 覆盖必须**完整**才敢自称 watcherActive,否则读路径不能享受 1 秒信任窗(见下)。
|
|
387
|
+
// 实测(Windows,`fs.watch` 非递归):手工编辑 `<base>/profile/x.md` 只触发**分类目录**
|
|
388
|
+
// 上的 watcher(`change x.md`),base 上的 watcher 一声不吭——所以分类目录的 watcher
|
|
389
|
+
// 是「改动能不能被发现」的唯一依据。
|
|
390
|
+
// ⚠️ 「不存在的分类目录」不算覆盖缺口:它装不下任何记录,而且它**被创建**时 base 上的
|
|
391
|
+
// watcher 会收到 `rename <category>`(同样实测过),照样能作废缓存。只有目录确实存在
|
|
392
|
+
// 却挂不上 watcher(句柄耗尽、权限、网络盘…)才是真缺口,那时必须每次读都做审计。
|
|
393
|
+
let complete = true;
|
|
125
394
|
for (const category of ALL_CATEGORIES) {
|
|
126
395
|
try {
|
|
127
|
-
|
|
396
|
+
watchCategory(join(base, CATEGORY_DIR[category]));
|
|
397
|
+
}
|
|
398
|
+
catch (error) {
|
|
399
|
+
if (error.code !== 'ENOENT')
|
|
400
|
+
complete = false;
|
|
128
401
|
}
|
|
129
|
-
catch { /* absent category */ }
|
|
130
402
|
}
|
|
131
403
|
const fallback = () => {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
if (cached)
|
|
137
|
-
watcherActiveFallback(cached);
|
|
404
|
+
closeScopeWatchers(state);
|
|
405
|
+
state.watcherRefreshRequired = true;
|
|
406
|
+
if (state.cache)
|
|
407
|
+
watcherActiveFallback(state.cache);
|
|
138
408
|
};
|
|
139
409
|
for (const watcher of watchers)
|
|
140
410
|
watcher.on('error', fallback);
|
|
141
|
-
|
|
142
|
-
|
|
411
|
+
state.watchers = watchers;
|
|
412
|
+
state.watcherComplete = complete;
|
|
413
|
+
return complete;
|
|
143
414
|
}
|
|
144
415
|
catch {
|
|
416
|
+
closeScopeWatchers(state);
|
|
145
417
|
return false;
|
|
146
418
|
}
|
|
147
419
|
}
|
|
148
420
|
function watcherActiveFallback(cached) {
|
|
149
|
-
cached.watcherActive = false;
|
|
150
421
|
cached.dirty = true;
|
|
151
422
|
}
|
|
152
423
|
function evictScopeCaches() {
|
|
153
|
-
if (
|
|
424
|
+
if (scopeStates.size <= MAX_SCOPE_CACHE_ENTRIES)
|
|
154
425
|
return;
|
|
155
|
-
const victims = [...
|
|
426
|
+
const victims = [...scopeStates.entries()]
|
|
156
427
|
.sort((a, b) => a[1].lastUsedAt - b[1].lastUsedAt)
|
|
157
|
-
.slice(0,
|
|
428
|
+
.slice(0, scopeStates.size - MAX_SCOPE_CACHE_ENTRIES);
|
|
158
429
|
for (const [key] of victims) {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
430
|
+
const state = scopeStates.get(key);
|
|
431
|
+
if (state)
|
|
432
|
+
closeScopeWatchers(state);
|
|
433
|
+
scopeStates.delete(key);
|
|
434
|
+
// ⚠️ 不能写 `path.startsWith(`${key}/`)`:Windows 上分隔符是 `\`,该条件恒为 false,
|
|
435
|
+
// 被淘汰 scope 的文件缓存永远清不掉 → fileCache 无上限增长。
|
|
163
436
|
for (const path of fileCache.keys()) {
|
|
164
|
-
if (
|
|
437
|
+
if (pathContains(key, path))
|
|
165
438
|
fileCache.delete(path);
|
|
166
439
|
}
|
|
167
440
|
}
|
|
168
441
|
}
|
|
442
|
+
async function settleRecentWatcherEvents(state) {
|
|
443
|
+
await new Promise((resolvePromise) => { setImmediate(resolvePromise); });
|
|
444
|
+
if (state.cache?.dirty)
|
|
445
|
+
return false;
|
|
446
|
+
const now = Date.now();
|
|
447
|
+
if (now - state.lastDrainAt >= WATCHER_SETTLE_REUSE_MS) {
|
|
448
|
+
await new Promise((resolvePromise) => {
|
|
449
|
+
setTimeout(resolvePromise, WATCHER_SETTLE_WAIT_MS);
|
|
450
|
+
});
|
|
451
|
+
state.lastDrainAt = Date.now();
|
|
452
|
+
}
|
|
453
|
+
// 正常 watcher 在上面的 settle 窗口中会直接打脏;扫描结束后的抑制窗口只把
|
|
454
|
+
// lastAuditAt 清零,故仅这一种情况需要逐文件核验。稳定缓存不做 stat 扫描。
|
|
455
|
+
return state.cache?.lastAuditAt === 0 || Date.now() < state.suppressDirtyUntil;
|
|
456
|
+
}
|
|
169
457
|
async function cachedFilesUnchanged(snapshot) {
|
|
170
458
|
const checks = await Promise.all(snapshot.records.map(async (record) => {
|
|
171
459
|
const path = memoryRecordPath(record);
|
|
@@ -225,47 +513,79 @@ async function readCached(path) {
|
|
|
225
513
|
return next;
|
|
226
514
|
}
|
|
227
515
|
}
|
|
228
|
-
export async function scanMemoryScope(scope, workspaceId) {
|
|
516
|
+
export async function scanMemoryScope(scope, workspaceId, attempt = 0) {
|
|
229
517
|
const base = scopeDirectory(scope, workspaceId);
|
|
230
518
|
const key = scopeKey(scope, workspaceId);
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
519
|
+
const state = scopeState(key);
|
|
520
|
+
const recovery = await recoverScopeTransaction(base);
|
|
521
|
+
let cachedScope = state.cache;
|
|
522
|
+
if (recovery.recovered && cachedScope)
|
|
234
523
|
cachedScope.dirty = true;
|
|
235
524
|
const generationBefore = await readGeneration(base);
|
|
236
525
|
const now = Date.now();
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
526
|
+
// ★ 读路径保持纯读:缓存命中直接返回快照,不再顺手 readFile / 写 views/topics.md。
|
|
527
|
+
// 旧实现把 ensureTopicView 挂在**所有**返回分支上,于是「缓存命中」仍有磁盘 IO,
|
|
528
|
+
// 而且 memoryAccess:'read-only' 的会话也会写盘——仓储的读 API 不该有副作用。
|
|
529
|
+
// 视图改由「全量扫描后」与「变更后」触发(见 refreshTopicView)。
|
|
530
|
+
// ★ 新鲜度两条腿,缺一不可:
|
|
531
|
+
//
|
|
532
|
+
// ① **排空一次事件循环**再判 dirty。`fs.watch` 的回调是排队派发的,「watcher 已挂上」
|
|
533
|
+
// 不等于「它已经把刚才那次改动告诉我了」。实测(Windows):改完文件后只要让出
|
|
534
|
+
// 一个 setImmediate tick,事件就已送达。这一步零 IO,却把「刚改完立刻读」这个
|
|
535
|
+
// 最常见的竞态消掉了。旧代码也想干这事,但把它锁在「距上次使用 <50ms」的条件里,
|
|
536
|
+
// 两次读之间只要多做点别的(哪怕只是写一次设置)就绕过去了。
|
|
537
|
+
//
|
|
538
|
+
// ② watcher 覆盖不全或压根没触发时,用 stat 审计兜底,节流到每秒最多一次。
|
|
539
|
+
// 审计是 O(记录数) 次 stat——实测 300 条记录约 40ms,放在每次读上太贵
|
|
540
|
+
// (每回合 recall + buildContextBundle 各一次),所以必须节流;但 30 秒的信任窗
|
|
541
|
+
// 又太松,手编要等半分钟才生效。1 秒是两边都能接受的点。
|
|
542
|
+
//
|
|
543
|
+
// ⚠️ 别再让读路径去写 `views/topics.md` 来「顺便」作废缓存——那是以前手编能被发现的
|
|
544
|
+
// 真实原因,纯属巧合,代价是每次读都做磁盘 IO。
|
|
545
|
+
let verifyAfterSettle = false;
|
|
246
546
|
if (cachedScope && !cachedScope.dirty) {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
547
|
+
verifyAfterSettle = await settleRecentWatcherEvents(state);
|
|
548
|
+
cachedScope = state.cache;
|
|
549
|
+
}
|
|
550
|
+
if (cachedScope && !cachedScope.dirty && verifyAfterSettle) {
|
|
551
|
+
repositoryDiagnostics.fallbackAudits += 1;
|
|
552
|
+
if (!await cachedFilesUnchanged(cachedScope.snapshot)
|
|
553
|
+
|| cachedScope.directoryFingerprint !== await directoryFingerprint(base)) {
|
|
554
|
+
cachedScope.dirty = true;
|
|
555
|
+
}
|
|
556
|
+
else {
|
|
557
|
+
cachedScope.lastAuditAt = Date.now();
|
|
251
558
|
}
|
|
252
|
-
|
|
559
|
+
}
|
|
560
|
+
if (cachedScope && !cachedScope.dirty) {
|
|
561
|
+
state.lastUsedAt = now;
|
|
562
|
+
// 只有 watcher 覆盖完整时才敢跳过审计——覆盖有缺口就等于没人会告诉我们变更,
|
|
563
|
+
// 那个 1 秒信任窗就失去依据,必须每次读都审计。
|
|
564
|
+
if (state.watcherComplete && now - cachedScope.lastAuditAt < SCOPE_AUDIT_TTL_MS) {
|
|
253
565
|
repositoryDiagnostics.snapshotHits += 1;
|
|
254
|
-
return
|
|
566
|
+
return cachedScope.snapshot;
|
|
255
567
|
}
|
|
256
568
|
repositoryDiagnostics.fallbackAudits += 1;
|
|
257
569
|
if (await cachedFilesUnchanged(cachedScope.snapshot)
|
|
258
570
|
&& cachedScope.directoryFingerprint === await directoryFingerprint(base)) {
|
|
259
|
-
cachedScope.checkedAt = now;
|
|
260
571
|
cachedScope.lastAuditAt = now;
|
|
261
572
|
repositoryDiagnostics.snapshotHits += 1;
|
|
262
|
-
return
|
|
573
|
+
return cachedScope.snapshot;
|
|
263
574
|
}
|
|
264
575
|
cachedScope.dirty = true;
|
|
265
576
|
}
|
|
266
577
|
repositoryDiagnostics.fullScans += 1;
|
|
578
|
+
// ★ 先挂 watcher 再读文件,并记下代际号。
|
|
579
|
+
// 旧顺序是「先把记录全读完,最后才挂 watcher」,中间那段窗口里发生的改动**两头都漏**:
|
|
580
|
+
// 读的时候还没改,watcher 又还没挂上——扫完却把这份已经过时的快照标记为干净缓存,
|
|
581
|
+
// 于是直到下一次兜底审计(现在是 1 秒)之前,谁都发现不了。这是原设计里的 TOCTOU。
|
|
582
|
+
// 现在:先挂 watcher,扫完再比对代际号,期间有任何改动就重扫。
|
|
583
|
+
const watcherActive = ensureScopeWatcher(base, scope, workspaceId);
|
|
584
|
+
const epochBefore = currentEpoch(key);
|
|
267
585
|
const records = [];
|
|
268
|
-
|
|
586
|
+
// 隔离事务留下的告警要跟着本次快照走完,不能只写日志然后消失——
|
|
587
|
+
// diagnostics 是既有的、Desktop 与 diagnosticSuffix() 都会展示的通道。
|
|
588
|
+
const diagnostics = await quarantineDiagnosticsFor(base);
|
|
269
589
|
const versions = {};
|
|
270
590
|
const seen = new Set();
|
|
271
591
|
for (const category of ALL_CATEGORIES) {
|
|
@@ -308,34 +628,43 @@ export async function scanMemoryScope(scope, workspaceId) {
|
|
|
308
628
|
}
|
|
309
629
|
}
|
|
310
630
|
}
|
|
631
|
+
// 同上:硬编码 `/` 在 Windows 上永不命中,已删除记录的缓存条目会永久滞留。
|
|
311
632
|
for (const path of fileCache.keys()) {
|
|
312
|
-
if (path.
|
|
313
|
-
path.endsWith('.md') &&
|
|
314
|
-
!seen.has(path)) {
|
|
633
|
+
if (path.endsWith('.md') && !seen.has(path) && pathContains(base, path)) {
|
|
315
634
|
fileCache.delete(path);
|
|
316
635
|
}
|
|
317
636
|
}
|
|
318
637
|
records.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
319
638
|
const snapshot = { records, versions, diagnostics, createdAt: Date.now() };
|
|
320
|
-
|
|
639
|
+
const tailRecovery = await recoverScopeTransaction(base);
|
|
640
|
+
if ((tailRecovery.recovered || generationBefore !== await readGeneration(base))
|
|
641
|
+
&& attempt < MAX_SCAN_RETRIES) {
|
|
321
642
|
invalidateScope(scope, workspaceId);
|
|
322
|
-
return scanMemoryScope(scope, workspaceId);
|
|
643
|
+
return scanMemoryScope(scope, workspaceId, attempt + 1);
|
|
323
644
|
}
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
645
|
+
// 让排队中的 watcher 回调先送达,再判断这次扫描期间有没有人改过东西。
|
|
646
|
+
await new Promise((resolvePromise) => { setImmediate(resolvePromise); });
|
|
647
|
+
// ⚠️ 重试必须有上限:持续写入的场景(cron 批量、另一个宿主导入、迁移)下每次重扫都会
|
|
648
|
+
// 看到新的代际号,无上限就是**活锁——读永远不返回**(还每轮重开 7 个 watcher)。
|
|
649
|
+
// 撞顶就照常缓存,让 1 秒兜底审计去纠正:最坏是几毫秒的陈旧快照且会自愈,
|
|
650
|
+
// 远好过一次挂死的 recall。
|
|
651
|
+
const changedDuringScan = currentEpoch(key) !== epochBefore;
|
|
652
|
+
if (changedDuringScan && attempt < MAX_SCAN_RETRIES) {
|
|
653
|
+
return scanMemoryScope(scope, workspaceId, attempt + 1);
|
|
654
|
+
}
|
|
655
|
+
state.cache = {
|
|
329
656
|
snapshot,
|
|
330
|
-
checkedAt: Date.now(),
|
|
331
657
|
dirty: false,
|
|
332
|
-
watcherActive,
|
|
333
658
|
directoryFingerprint: await directoryFingerprint(base),
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
659
|
+
// 撞了重试上限才落库的快照是**已知可能陈旧**的,不能再送它一个完整的 1 秒信任窗——
|
|
660
|
+
// 把审计时间戳清零,下一次读立刻走 stat 审计纠正。
|
|
661
|
+
lastAuditAt: changedDuringScan ? 0 : Date.now(),
|
|
662
|
+
};
|
|
663
|
+
state.watcherComplete = watcherActive;
|
|
664
|
+
state.lastUsedAt = Date.now();
|
|
665
|
+
state.suppressDirtyUntil = Date.now() + 100;
|
|
337
666
|
evictScopeCaches();
|
|
338
|
-
return
|
|
667
|
+
return snapshot;
|
|
339
668
|
}
|
|
340
669
|
export async function readVersionedMemory(id, scope, workspaceId) {
|
|
341
670
|
const snapshot = await scanMemoryScope(scope, workspaceId);
|
|
@@ -345,6 +674,7 @@ export async function readVersionedMemory(id, scope, workspaceId) {
|
|
|
345
674
|
export async function writeNewMemory(record) {
|
|
346
675
|
const path = memoryRecordPath(record);
|
|
347
676
|
const content = serializeMemoryRecord(record);
|
|
677
|
+
markInternalRecordEvent(path);
|
|
348
678
|
await atomicWriteFile(path, content);
|
|
349
679
|
fileCache.delete(path);
|
|
350
680
|
invalidateScope(record.scope, record.workspaceId);
|
|
@@ -434,6 +764,7 @@ export async function removeVersionedMemory(current, expectedVersion) {
|
|
|
434
764
|
throw new Error('MEMORY_VERSION_CONFLICT');
|
|
435
765
|
}
|
|
436
766
|
try {
|
|
767
|
+
markInternalRecordEvent(path);
|
|
437
768
|
await unlink(path);
|
|
438
769
|
fileCache.delete(path);
|
|
439
770
|
invalidateScope(current.record.scope, current.record.workspaceId);
|
|
@@ -451,9 +782,8 @@ export async function removeVersionedMemory(current, expectedVersion) {
|
|
|
451
782
|
}
|
|
452
783
|
export function clearMemoryIndex() {
|
|
453
784
|
fileCache.clear();
|
|
454
|
-
|
|
455
|
-
for (const
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
scopeWatchers.clear();
|
|
785
|
+
internalRecordEvents.clear();
|
|
786
|
+
for (const state of scopeStates.values())
|
|
787
|
+
closeScopeWatchers(state);
|
|
788
|
+
scopeStates.clear();
|
|
459
789
|
}
|