sage-mem 2.1.7 → 2.1.9
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/README.md +1 -1
- package/README.zh.md +1 -1
- package/lib/client.js +4 -0
- package/lib/index.js +25 -27
- package/lib/memory/arbitrate.js +85 -2
- package/lib/memory/util.js +56 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
**Cross-session memory for DeepSeek Harness — plain markdown files, no database, no worker.**
|
|
14
14
|
|
|
15
|
-
**Current release: `2.1.
|
|
15
|
+
**Current release: `2.1.9`** — the npm badge above is the dynamic source of truth. **Contract baseline: `1.0.0`** — the first stable release, whose public commitments, the capability boundaries and the list of things it explicitly cannot do are frozen in the
|
|
16
16
|
[frozen contract](https://github.com/gezi-wen/sage-mem/blob/main/docs/1.0.0-contract.md).
|
|
17
17
|
|
|
18
18
|
If it is useful to you, a Star is the most direct feedback.
|
package/README.zh.md
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
> File-based cross-session memory for DeepSeek Harness — every memory is a plain markdown file you can open, edit and delete. No database, no worker, no port.
|
|
16
16
|
|
|
17
|
-
**当前发布版本:`2.1.
|
|
17
|
+
**当前发布版本:`2.1.9`** —— 顶部 npm 徽章是动态权威。**契约基线:`1.0.0`** —— 第一个稳定版,公开承诺、能力边界与「明确做不到」的清单冻结在
|
|
18
18
|
[契约冻结](https://github.com/gezi-wen/sage-mem/blob/main/docs/1.0.0-contract.md)。
|
|
19
19
|
|
|
20
20
|
如果它对你有用,给个 Star 是最直接的反馈。
|
package/lib/client.js
CHANGED
|
@@ -3223,6 +3223,8 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
|
|
|
3223
3223
|
malformed: "conflict.code.malformed",
|
|
3224
3224
|
invalid_transition: "conflict.code.invalidTransition",
|
|
3225
3225
|
stale_version: "conflict.code.staleVersion",
|
|
3226
|
+
// 2.1.9(N01):撤销时发现标记已被后来人重写 —— 不删别人的判断,交给用户比较。
|
|
3227
|
+
stale_decision: "conflict.code.staleDecision",
|
|
3226
3228
|
write_denied: "conflict.code.writeDenied",
|
|
3227
3229
|
unsupported: "conflict.code.unsupported",
|
|
3228
3230
|
io_error: "conflict.code.ioError",
|
|
@@ -3909,6 +3911,7 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
|
|
|
3909
3911
|
"conflict.code.malformed": "这条记录格式损坏:可以删除记录,或手工检查状态目录里的 JSON。",
|
|
3910
3912
|
"conflict.code.invalidTransition": "这条记录的状态已经变了:刷新列表后按当前状态再操作。",
|
|
3911
3913
|
"conflict.code.staleVersion": "记忆文件在你打开之后被改过:先刷新列表、确认最新内容再裁决(本次不会覆盖你的改动)。",
|
|
3914
|
+
"conflict.code.staleDecision": "文件上的失效标记已经被后来人重写过:撤销它等于删掉别人的判断,本次没有改动任何字节。请先比较记录里的 invalid_at / superseded_by 与盘上现值,确认后再决定。",
|
|
3912
3915
|
"conflict.code.writeDenied": "宿主拒绝了写入(只读 / 沙箱策略):把 DSH 的写入权限调宽后重试,或手工在记忆 frontmatter 里加 invalid_at。",
|
|
3913
3916
|
"conflict.code.unsupported": "宿主没有可用的写入接口:升级插件,或手工在记忆 frontmatter 里加 invalid_at。",
|
|
3914
3917
|
"conflict.code.ioError": "读写磁盘失败:稍后重试;记录仍是待裁决状态,不会留下半份文件。",
|
|
@@ -4387,6 +4390,7 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
|
|
|
4387
4390
|
"conflict.code.malformed": "This record is malformed: delete the record, or inspect the JSON in the state directory by hand.",
|
|
4388
4391
|
"conflict.code.invalidTransition": "This record already changed state: refresh the list and act on its current state.",
|
|
4389
4392
|
"conflict.code.staleVersion": "The memory file changed after you opened it: refresh the list and check the latest content before deciding (your changes are not overwritten).",
|
|
4393
|
+
"conflict.code.staleDecision": "The validity marker on disk was rewritten by someone else: undoing it would erase their decision, so nothing was changed. Compare invalid_at / superseded_by in the record with the current values before deciding.",
|
|
4390
4394
|
"conflict.code.writeDenied": "The host refused the write (read-only / sandbox policy): widen DSH write permissions and retry, or add invalid_at to the memory frontmatter by hand.",
|
|
4391
4395
|
"conflict.code.unsupported": "The host has no usable write API: upgrade the plugin, or add invalid_at to the memory frontmatter by hand.",
|
|
4392
4396
|
"conflict.code.ioError": "Disk read/write failed: retry later; the record stays pending and no half-written file is left behind.",
|
package/lib/index.js
CHANGED
|
@@ -1,17 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* sage-mem —
|
|
2
|
+
* sage-mem — 文件式记忆插件。记忆存在本地 markdown 目录(frontmatter + 正文,4 类),不是数据库。
|
|
3
|
+
* host 侧三件事:① 按问题检索注入:system-prompt/assemble 时扫 memory 目录、按双字匹配选相关文件,
|
|
4
|
+
* 读全文注入 system prompt,让 agent 第一轮就「想起」;② TypertRemoteService:给设置页「记忆管理」
|
|
5
|
+
* 提供文件列表/读/写/删;③ autodream(自动做梦):可选的记忆整理,见 lib/autodream.js。
|
|
3
6
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* 1. 按问题检索注入:system-prompt/assemble 时扫 memory 目录,按双字匹配
|
|
7
|
-
* 选相关文件,读全文注入 system prompt,让 agent 第一轮就「想起」
|
|
8
|
-
* 2. TypertRemoteService:给设置页「记忆管理」提供文件列表/读/写/删
|
|
9
|
-
* 3. autodream(自动做梦):可选的记忆整理,见 lib/autodream.js
|
|
10
|
-
*
|
|
11
|
-
* 写入靠 AGENTS.md 的「记忆使用」规则引导 agent 自己判断 + 用文件工具写
|
|
12
|
-
* memory 目录——透明、可检查、防膨胀。无 worker、无 SQLite、无端口。
|
|
13
|
-
*
|
|
14
|
-
* 旧 worker 版见 git 分支 `sqlite-worker`。
|
|
7
|
+
* 写入靠 AGENTS.md 的「记忆使用」规则引导 agent 自己判断 + 用文件工具写 memory 目录——透明、可检查、
|
|
8
|
+
* 防膨胀。无 worker、无 SQLite、无端口。旧 worker 版见 git 分支 `sqlite-worker`。
|
|
15
9
|
*/
|
|
16
10
|
|
|
17
11
|
import { AutodreamEngine, DEFAULT_CONFIG, auditMemoryDir } from './autodream.js'
|
|
@@ -26,7 +20,7 @@ import { invalidateMemory, undoMemoryInvalidation } from './memory/arbitrate.js'
|
|
|
26
20
|
import { readSnapshot, writeSnapshot } from './memory/write-protocol.js'
|
|
27
21
|
import { archiveMemory, archivedRevisionOf, deleteArchivedMemory, restoreMemory } from './memory/archive.js'
|
|
28
22
|
import { flushAccess, pickArchiveCandidates, readAccessLedger } from './memory/access.js'
|
|
29
|
-
import { warnLog, writeFileAtomic } from './memory/util.js'
|
|
23
|
+
import { serializeByKey, warnLog, writeFileAtomic } from './memory/util.js'
|
|
30
24
|
import { readLockSet, setLocked as setLockedImpl } from './memory/lock.js'
|
|
31
25
|
import { openMemoryFolder } from './memory/open-folder.js'
|
|
32
26
|
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
|
@@ -694,20 +688,14 @@ markRemote(StarmapGateway, 'listStars', 'listStars')
|
|
|
694
688
|
markRemote(StarmapGateway, 'readFile', 'readFile')
|
|
695
689
|
|
|
696
690
|
/**
|
|
697
|
-
* 2.0.0
|
|
691
|
+
* 2.0.0 的裁决入口:冲突队列的「可列、可读、可裁、可撤销」。单独一个 namespace(`conflict`)
|
|
692
|
+
* 不并进 `memory`:这一层会**写用户的记忆文件**,与 memory 的读写正文不是一个权限面。
|
|
698
693
|
*
|
|
699
|
-
*
|
|
700
|
-
*
|
|
701
|
-
*
|
|
702
|
-
*
|
|
703
|
-
*
|
|
704
|
-
* 1. **先写文件、后改记录**。反过来会出现「记录说已裁决、文件没改」的半态,
|
|
705
|
-
* 用户再也看不到它(记录已 resolved,文件仍照旧注入)。
|
|
706
|
-
* 2. `invalidate` 只能写**这条记录自己提到的那一方**(`file` / `otherFile`)——
|
|
707
|
-
* 防「借裁决入口写任意记忆文件」。文件名本身还要再过 `safeName` 闸门。
|
|
708
|
-
* 3. 失败一律返回对象、绝不抛;`ok` 只在真的做了报告里那件事时为 true。
|
|
709
|
-
* 4. 写路径走宿主 `fs.writeText`:版本守卫 + 逐次沙箱策略(收窄到记忆目录),
|
|
710
|
-
* 与 autodream 同一口径 —— `read-only` 不替用户翻案。
|
|
694
|
+
* 四条硬约束(`docs/2.0.0-plan.md` §5.5 / §7.3 / §7.6):① 先写文件、后改记录(反过来会出现
|
|
695
|
+
* 「记录说已裁决、文件没改」的半态,用户再也看不到它);② `invalidate` 只能写这条记录自己提到的
|
|
696
|
+
* 那一方(`file` / `otherFile`,文件名再过 `safeName` 闸门),防「借裁决入口写任意记忆文件」;
|
|
697
|
+
* ③ 失败一律返回对象、绝不抛,`ok` 只在真的做了报告里那件事时为 true;④ 写路径走宿主
|
|
698
|
+
* `fs.writeText`:版本守卫 + 逐次沙箱策略(收窄到记忆目录),与 autodream 同一口径。
|
|
711
699
|
*/
|
|
712
700
|
export class ConflictGateway extends TypertRemoteService {
|
|
713
701
|
constructor(ctx) { super(ctx, 'conflict') }
|
|
@@ -809,6 +797,9 @@ export class ConflictGateway extends TypertRemoteService {
|
|
|
809
797
|
async dismiss(opts) {
|
|
810
798
|
const read = await readConflict(opts?.id, { stateRoot: STATE_ROOT })
|
|
811
799
|
if (!read.ok) return { ok: false, code: read.code, error: read.error }
|
|
800
|
+
// 2.1.8(N02):已裁决为「标失效」的记录不能直接驳回 —— 记忆上的失效标记还在,
|
|
801
|
+
// 驳回只会让它变成一条没有撤销入口的孤儿标记。要收掉判断请先撤销。
|
|
802
|
+
if (read.record.status === 'resolved' && read.record.decision === 'invalidate') return { ok: false, code: 'invalid_transition', error: '这条记录已裁决为失效且记忆仍带失效标记:先撤销,再决定要不要驳回' }
|
|
812
803
|
// 记录里的 `note` 是冲突的由来(写进记录那一刻的事实),裁决理由**不覆盖**它 ——
|
|
813
804
|
// 覆盖等于把证据改写成结论。
|
|
814
805
|
const patched = await patchConflict(
|
|
@@ -834,7 +825,7 @@ export class ConflictGateway extends TypertRemoteService {
|
|
|
834
825
|
return { ok: false, code: 'invalid_transition', error: '这条记录没有标过失效,没有可撤销的标记' }
|
|
835
826
|
}
|
|
836
827
|
const target = record.invalidatedFile || record.file
|
|
837
|
-
const cleared = await undoMemoryInvalidation({ ctx: this.ctx, memoryDir: MEMORY_DIR }, target)
|
|
828
|
+
const cleared = await undoMemoryInvalidation({ ctx: this.ctx, memoryDir: MEMORY_DIR }, target, record.decidedAt ? { expectedInvalidAt: record.decidedAt, expectedSupersededBy: target === record.file ? record.otherFile : record.file } : undefined)
|
|
838
829
|
if (!cleared.ok) return { ok: false, code: cleared.code, error: cleared.error }
|
|
839
830
|
|
|
840
831
|
const patched = await patchConflict(
|
|
@@ -863,6 +854,10 @@ export class ConflictGateway extends TypertRemoteService {
|
|
|
863
854
|
* 这条改名不影响对外承诺。
|
|
864
855
|
*/
|
|
865
856
|
async removeRecord(opts) {
|
|
857
|
+
// 2.1.8(N02):删掉一条「记忆仍标着失效」的记录等于删掉撤销入口 —— 标记就永久留在了
|
|
858
|
+
// 用户文件里。先撤销、再删记录。读不出来(坏记录)时照旧允许删:那是清理坏数据的出路。
|
|
859
|
+
const cur = await readConflict(opts?.id, { stateRoot: STATE_ROOT })
|
|
860
|
+
if (cur.ok && cur.record.status === 'resolved' && cur.record.decision === 'invalidate') return { ok: false, code: 'invalid_transition', error: '这条记录的记忆仍标着失效:先撤销(撤销会清掉标记)再删记录' }
|
|
866
861
|
const removed = await removeConflict(opts?.id, { stateRoot: STATE_ROOT })
|
|
867
862
|
if (!removed.ok) return { ok: false, code: removed.code, error: removed.error }
|
|
868
863
|
return { ok: true }
|
|
@@ -876,6 +871,9 @@ markRemote(ConflictGateway, 'dismiss', 'dismiss')
|
|
|
876
871
|
markRemote(ConflictGateway, 'undo', 'undo')
|
|
877
872
|
markRemote(ConflictGateway, 'removeRecord', 'removeRecord')
|
|
878
873
|
|
|
874
|
+
// 2.1.8(N02):裁决入口整段按冲突 id 串行(第二方进队后重新读状态;理由见 util.js 的 serializeByKey)。
|
|
875
|
+
serializeByKey(ConflictGateway, ['invalidate', 'dismiss', 'undo', 'removeRecord'])
|
|
876
|
+
|
|
879
877
|
/**
|
|
880
878
|
* autodream 的 remote 面:设置页「自动做梦」标签页唯一的数据通道。
|
|
881
879
|
*
|
package/lib/memory/arbitrate.js
CHANGED
|
@@ -19,13 +19,29 @@
|
|
|
19
19
|
import { join } from 'node:path'
|
|
20
20
|
import { inspectMemoryEntry, readVerified, yamlScalar } from './scan.js'
|
|
21
21
|
import { safeName } from './naming.js'
|
|
22
|
+
import { createKeyedWriteQueue } from './util.js'
|
|
22
23
|
import { parseValidityTime } from './validity.js'
|
|
23
24
|
|
|
25
|
+
/**
|
|
26
|
+
* 2.1.8(外部审查 N02):同一份记忆文件的「读快照 → 改标记 → 写回」整段串行。
|
|
27
|
+
*
|
|
28
|
+
* 单文件的版本 CAS 只覆盖「这一次读写窗口」,保护不了「两个不同冲突同时标同一文件」:
|
|
29
|
+
* 两边都基于旧字节改写,后写的那个会把前一个标记静默盖掉,而两份收据都声称成功。
|
|
30
|
+
* 串行之后后一个读到的就是前一个写完的字节(跨进程仍由宿主 replaceIfVersion 兜底)。
|
|
31
|
+
*/
|
|
32
|
+
const withFileLock = createKeyedWriteQueue()
|
|
33
|
+
|
|
24
34
|
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/
|
|
25
35
|
|
|
26
36
|
/** 失效标记的两行(只认顶层键:`metadata:` 嵌套块里同名的键不是我们的)。 */
|
|
27
37
|
const VALIDITY_LINE_RE = /^(?:invalid_at|superseded_by):/
|
|
28
38
|
|
|
39
|
+
/**
|
|
40
|
+
* 2.1.9(外部审查 N01):撤销时发现「文件上的标记已经不是这条记录写的那份」。
|
|
41
|
+
* 固定字符串,界面与测试都只认它(不解析错误文案)。
|
|
42
|
+
*/
|
|
43
|
+
export const ERR_STALE_DECISION = 'stale_decision'
|
|
44
|
+
|
|
29
45
|
function eolOf(text) {
|
|
30
46
|
const src = String(text ?? '')
|
|
31
47
|
const i = src.indexOf('\r\n')
|
|
@@ -87,6 +103,36 @@ export function removeValidityLines(content) {
|
|
|
87
103
|
return { ok: true, content: next, changed: next !== content }
|
|
88
104
|
}
|
|
89
105
|
|
|
106
|
+
/** 裸值 / 单引号 / 双引号(`yamlScalar` 会按需加引号)→ 原始字符串。 */
|
|
107
|
+
function unquoteScalar(raw) {
|
|
108
|
+
const s = String(raw ?? '').trim()
|
|
109
|
+
if (s.length >= 2 && s[0] === "'" && s[s.length - 1] === "'") return s.slice(1, -1).replace(/''/g, "'")
|
|
110
|
+
if (s.length >= 2 && s[0] === '"' && s[s.length - 1] === '"') return s.slice(1, -1)
|
|
111
|
+
return s
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 读文件**当前**的失效标记(只认顶层两行)。没有 `invalid_at` 就返回 null。
|
|
116
|
+
*
|
|
117
|
+
* 2.1.9(N01):撤销要先知道「现在文件上写的是哪一次判断」。读回来的值可能是带引号的
|
|
118
|
+
* 形式(`yamlScalar` 对含 `: ` 之类的值会加引号),所以这里统一去引号后再比对。
|
|
119
|
+
*
|
|
120
|
+
* @returns {{invalidAt:string, supersededBy:string} | null}
|
|
121
|
+
*/
|
|
122
|
+
export function readValidityMarker(content) {
|
|
123
|
+
if (typeof content !== 'string') return null
|
|
124
|
+
const m = content.match(FRONTMATTER_RE)
|
|
125
|
+
if (!m) return null
|
|
126
|
+
const marker = { invalidAt: '', supersededBy: '' }
|
|
127
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
128
|
+
const hit = line.match(/^(invalid_at|superseded_by)\s*:\s*(.*)$/)
|
|
129
|
+
if (!hit) continue
|
|
130
|
+
if (hit[1] === 'invalid_at') marker.invalidAt = unquoteScalar(hit[2])
|
|
131
|
+
else marker.supersededBy = unquoteScalar(hit[2])
|
|
132
|
+
}
|
|
133
|
+
return marker.invalidAt ? marker : null
|
|
134
|
+
}
|
|
135
|
+
|
|
90
136
|
/** 从宿主的 `fs` 服务取版本(拿不到就是 undefined —— 那就**不写**,不拿「现在」冒充)。 */
|
|
91
137
|
function versionOf(info) {
|
|
92
138
|
return info && info.version !== undefined && info.version !== null ? info.version : undefined
|
|
@@ -177,6 +223,11 @@ async function readForWrite(ctx, memoryDir, file) {
|
|
|
177
223
|
* | {ok:false, code:'invalid'|'missing'|'stale_version'|'write_denied'|'unsupported'|'io_error', error:string}>}
|
|
178
224
|
*/
|
|
179
225
|
export async function invalidateMemory({ ctx, memoryDir }, input) {
|
|
226
|
+
// 2.1.8(N02):按记忆文件串行(见 withFileLock 的注释)。文件名归一后当 key。
|
|
227
|
+
return withFileLock(String(safeName(input?.file) ?? ''), () => invalidateMemoryLocked({ ctx, memoryDir }, input))
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function invalidateMemoryLocked({ ctx, memoryDir }, input) {
|
|
180
231
|
const invalidAt = typeof input?.invalidAt === 'string' ? input.invalidAt.trim() : ''
|
|
181
232
|
// 写进用户文件的标记必须是我们能解析的格式:坏值会被 validity 层按「未标注」处理、
|
|
182
233
|
// 记忆照常注入 —— 用户会看到「标了却没用」,那是最难查的一类静默失效。
|
|
@@ -209,12 +260,44 @@ export async function invalidateMemory({ ctx, memoryDir }, input) {
|
|
|
209
260
|
/**
|
|
210
261
|
* 撤销失效:删掉 `invalid_at` / `superseded_by` 两行,其余字节不动。
|
|
211
262
|
*
|
|
263
|
+
* 2.1.9(N01):可选第三参 `opts` 是**这次撤销的操作身份**(来自冲突记录:
|
|
264
|
+
* `expectedInvalidAt = record.decidedAt`,`expectedSupersededBy` = 记录里的另一方)。
|
|
265
|
+
* 文件当前标记对不上 → `stale_decision`,**一个字节都不删**:后来人改过标记,那已经是
|
|
266
|
+
* 别人的判断,撤销它等于把别人的决定抹掉。正文变化不参与比对 —— 撤销只删两行标记,
|
|
267
|
+
* 正文照旧保留。
|
|
268
|
+
*
|
|
269
|
+
* @param {object} deps `{ctx, memoryDir}`
|
|
270
|
+
* @param {string} file
|
|
271
|
+
* @param {{expectedInvalidAt?:string, expectedSupersededBy?:string}} [opts]
|
|
212
272
|
* @returns {Promise<{ok:true, file:string, cleared:boolean, version:string}
|
|
213
|
-
* | {ok:false, code:'invalid'|'missing'|'stale_version'|'write_denied'|'unsupported'|'io_error', error:string}>}
|
|
273
|
+
* | {ok:false, code:'invalid'|'missing'|'stale_version'|'stale_decision'|'write_denied'|'unsupported'|'io_error', error:string}>}
|
|
214
274
|
*/
|
|
215
|
-
export async function undoMemoryInvalidation({ ctx, memoryDir }, file) {
|
|
275
|
+
export async function undoMemoryInvalidation({ ctx, memoryDir }, file, opts) {
|
|
276
|
+
// 2.1.8(N02):与 invalidateMemory 同一条文件级串行(撤销和标记可能同时发生)。
|
|
277
|
+
return withFileLock(String(safeName(file) ?? ''), () => undoMemoryInvalidationLocked({ ctx, memoryDir }, file, opts))
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function undoMemoryInvalidationLocked({ ctx, memoryDir }, file, opts) {
|
|
216
281
|
const read = await readForWrite(ctx, memoryDir, file)
|
|
217
282
|
if (!read.ok) return read
|
|
283
|
+
// 2.1.9(N01):先确认「现在文件上的标记仍是这条记录写的那一份」,再决定删不删。
|
|
284
|
+
const expect = opts && typeof opts === 'object' ? opts : null
|
|
285
|
+
const expectedInvalidAt = typeof expect?.expectedInvalidAt === 'string' ? expect.expectedInvalidAt : ''
|
|
286
|
+
if (expectedInvalidAt) {
|
|
287
|
+
const marker = readValidityMarker(read.content)
|
|
288
|
+
if (!marker) {
|
|
289
|
+
// 标记已经不在了(可能已撤销过 / 被别的写入清掉):不删任何东西,幂等返回。
|
|
290
|
+
return { ok: true, file: safeName(file), cleared: false, version: String(read.version ?? '') }
|
|
291
|
+
}
|
|
292
|
+
const expectedSupersededBy = typeof expect?.expectedSupersededBy === 'string' ? expect.expectedSupersededBy : ''
|
|
293
|
+
if (marker.invalidAt !== expectedInvalidAt || marker.supersededBy !== expectedSupersededBy) {
|
|
294
|
+
return {
|
|
295
|
+
ok: false,
|
|
296
|
+
code: ERR_STALE_DECISION,
|
|
297
|
+
error: `文件上的失效标记已不是这条记录写下的那一份(记录:invalid_at=${expectedInvalidAt} / superseded_by=${expectedSupersededBy};盘上:invalid_at=${marker.invalidAt} / superseded_by=${marker.supersededBy})—— 没有删除别人的判断,请先比较两边内容`,
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
218
301
|
const built = removeValidityLines(read.content)
|
|
219
302
|
if (!built.ok) return built
|
|
220
303
|
if (!built.changed) {
|
package/lib/memory/util.js
CHANGED
|
@@ -290,6 +290,62 @@ export function createWriteQueue() {
|
|
|
290
290
|
}
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
+
/**
|
|
294
|
+
* 造一个**按 key 分队列**的进程内串行器(2.1.8 / 外部审查 N02 引入)。
|
|
295
|
+
*
|
|
296
|
+
* 为什么不能用一个全局队列:冲突裁决按 conflict id 串行,但不同 id 之间不该互相排队 ——
|
|
297
|
+
* 一条卡住的写入不该拖住整张冲突表。同一个 key 上,整段「读记录 → 写记忆 → 写收据」
|
|
298
|
+
* 必须一个接一个:两个相反裁决并发时,各自改不同记忆文件都能合法成功,最后同一份收据
|
|
299
|
+
* 只剩最后一份,而两份记忆都失效了 —— 单文件的版本 CAS 保护不了整项操作。
|
|
300
|
+
*
|
|
301
|
+
* 队列空闲后把 entry 摘掉:长进程里 id 会不断累积,不能每个 key 永久留一条闭包链。
|
|
302
|
+
* ⚠️ 与 `createWriteQueue` 同一条边界:**进程内**串行,不是跨进程锁。
|
|
303
|
+
*
|
|
304
|
+
* @returns {(key:string, fn:()=>Promise<any>)=>Promise<any>}
|
|
305
|
+
*/
|
|
306
|
+
export function createKeyedWriteQueue() {
|
|
307
|
+
const entries = new Map()
|
|
308
|
+
return function enqueue(key, fn) {
|
|
309
|
+
const k = String(key)
|
|
310
|
+
let entry = entries.get(k)
|
|
311
|
+
if (!entry) {
|
|
312
|
+
entry = { queue: createWriteQueue(), pending: 0 }
|
|
313
|
+
entries.set(k, entry)
|
|
314
|
+
}
|
|
315
|
+
entry.pending += 1
|
|
316
|
+
const done = () => {
|
|
317
|
+
entry.pending -= 1
|
|
318
|
+
// pending 归零且这一条还是当前那条时才摘:期间有人入队的话 pending 不会到 0。
|
|
319
|
+
if (entry.pending === 0 && entries.get(k) === entry) entries.delete(k)
|
|
320
|
+
}
|
|
321
|
+
const run = entry.queue(fn)
|
|
322
|
+
// 只挂回调、不改返回的那条 promise(失败仍由调用方接住)。
|
|
323
|
+
run.then(done, done)
|
|
324
|
+
return run
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* 把一组「第一个参数是 `{id}`」的 remote 方法按 id 串行(2.1.8 / N02 的用法)。
|
|
330
|
+
*
|
|
331
|
+
* 为什么用原型包装而不是给每个方法再写一层:四个入口(invalidate / dismiss / undo /
|
|
332
|
+
* removeRecord)的**方法体本身要重新读状态**(这是队列内重读的实现),外面只多一层
|
|
333
|
+
* 「按 id 排队」的接线。包装发生在 class 定义之后、宿主清单之外;typert 的调用是
|
|
334
|
+
* `Reflect.get(receiver, method)` 现取,所以包装后的原型方法就是真正被执行的那个。
|
|
335
|
+
*
|
|
336
|
+
* @param {Function} cls — 目标类(方法定义在它的 prototype 上)
|
|
337
|
+
* @param {string[]} methods — 方法名
|
|
338
|
+
*/
|
|
339
|
+
export function serializeByKey(cls, methods) {
|
|
340
|
+
const withLock = createKeyedWriteQueue()
|
|
341
|
+
for (const name of methods) {
|
|
342
|
+
const run = cls.prototype[name]
|
|
343
|
+
cls.prototype[name] = function serializeOne(first) {
|
|
344
|
+
return withLock(String(first?.id ?? ''), () => run.call(this, first))
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
293
349
|
/**
|
|
294
350
|
* ⚠️ 回滚写回请用 **`writeFileAtomic`**,不要用 `copyFile(src, dst)`。
|
|
295
351
|
*
|
package/package.json
CHANGED