dsh-session-messenger 0.4.2

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/negotiation.js ADDED
@@ -0,0 +1,415 @@
1
+ /**
2
+ * NegotiationStore — 冲突协商的状态机与持久化(纯状态 + 决策,零副作用)。
3
+ *
4
+ * 设计原则:
5
+ * 1. 结构化意图:offer/counter/accept/decline/escalate 是一等字段,message 只是附注。
6
+ * 2. 权限边界:只有 claim 持有方能提出 release-*;写入方只能提 wait-until 或 decline。
7
+ * 3. 沉默不等于同意:无响应只走看门狗升级人工,默认保守结果 = 占用方保留 claim。
8
+ * 4. 每次意图刷新活性 deadline;轮次超限或超时 → escalated。
9
+ * 5. 状态落盘(JsonFile 合并写),重启后可恢复;定时器由调用方在激活时补跑。
10
+ *
11
+ * 审查修复:
12
+ * - M2:带 pending 释放/唤醒的终态记录不参与剪枝,直到 pending 执行完毕。
13
+ * - M3:限速键为 (negId, 发送方),只限制同一方连发,不误伤对方的及时回应。
14
+ * - L3:终态协商对同 (path, pair) 设冷静期,冷静期内不重复开桌、不重复打扰。
15
+ * - H1 支撑:frozenFor() 暴露「争议中」路径,供写入守卫只冻结真正有争议的单个路径。
16
+ *
17
+ * v0.4.2 同一文件多写入方(移交 + 排队):
18
+ * - 谈成(accept release-now / release-at 到点)不再「释放后谁抢到算谁的」,而是把占用
19
+ * **直接移交**给谈成的写入方(effects.handoff)。
20
+ * - 占用方已约定移交后,该文件上其余协商进入 `queued`(非终态、冻结写入、不走超时升级),
21
+ * 新来的写入方也直接排进 `queued`;移交完成后按先来后到**改绑**到新占用方重新 `open`。
22
+ * - 占用方主动释放 / 过期 / 被销毁时,由调用方按队列把占用交给下一位(queueFor)。
23
+ */
24
+ import path from 'node:path';
25
+ import { JsonFile } from './storage.js';
26
+ import { pathKey } from './util.js';
27
+
28
+ export const TERMINAL_STATES = new Set(['accepted', 'declined', 'escalated', 'resolved']);
29
+ /** 非终态:open(可协商)与 queued(排在已约定的移交之后)。 */
30
+ export const ACTIVE_STATES = new Set(['open', 'queued']);
31
+ const RELEASE_ACTIONS = new Set(['release-now', 'release-at']);
32
+ const MAX_AHEAD_MS = 60 * 60 * 1000;
33
+
34
+ function pairKey(key, a, b) {
35
+ const [x, y] = [String(a), String(b)].sort();
36
+ return `${key}\u0000${x}\u0000${y}`;
37
+ }
38
+
39
+ export function normalizeTerms(terms) {
40
+ if (!terms || typeof terms !== 'object') return undefined;
41
+ const action = String(terms.action || '').trim();
42
+ if (!['release-now', 'release-at', 'wait-until'].includes(action)) return undefined;
43
+ if (action === 'release-now') return { action };
44
+ const at = Date.parse(String(terms.at || ''));
45
+ if (!Number.isFinite(at)) return undefined;
46
+ return { action, at };
47
+ }
48
+
49
+ export class NegotiationStore {
50
+ constructor({ dataDir, logger, deadlineMs, maxRounds, rateMs, cooldownMs = 10 * 60 * 1000, maxHistory = 12 }) {
51
+ this.file = new JsonFile(path.join(dataDir, 'negotiations.json'));
52
+ this.logger = logger;
53
+ this.deadlineMs = deadlineMs;
54
+ this.maxRounds = maxRounds;
55
+ this.rateMs = rateMs;
56
+ this.cooldownMs = cooldownMs;
57
+ this.maxHistory = maxHistory;
58
+ /** @type {Map<string, object>} */
59
+ this.negotiations = new Map();
60
+ this.ready = false;
61
+ /** @type {Map<string, number>} `${negId}\0${by}` -> lastIntentAt */
62
+ this.rate = new Map();
63
+ }
64
+
65
+ async load(now) {
66
+ if (this.ready) return;
67
+ try {
68
+ const parsed = await this.file.read();
69
+ const list = parsed && Array.isArray(parsed.negotiations) ? parsed.negotiations : [];
70
+ for (const neg of list) {
71
+ if (!neg || typeof neg.id !== 'string' || typeof neg.path !== 'string' || !neg.a || !neg.b) continue;
72
+ const state = TERMINAL_STATES.has(neg.state) || ACTIVE_STATES.has(neg.state) ? neg.state : 'open';
73
+ this.negotiations.set(neg.id, {
74
+ id: neg.id,
75
+ path: neg.path,
76
+ key: typeof neg.key === 'string' && neg.key ? neg.key : pathKey(neg.path),
77
+ a: String(neg.a),
78
+ b: String(neg.b),
79
+ labels: neg.labels && typeof neg.labels === 'object' ? neg.labels : {},
80
+ holder: String(neg.holder || ''),
81
+ writer: String(neg.writer || ''),
82
+ writerMeta: neg.writerMeta && typeof neg.writerMeta === 'object' ? neg.writerMeta : {},
83
+ handoffTo: typeof neg.handoffTo === 'string' ? neg.handoffTo : '',
84
+ state,
85
+ rounds: Number(neg.rounds) || 0,
86
+ createdAt: Number(neg.createdAt) || now,
87
+ updatedAt: Number(neg.updatedAt) || now,
88
+ deadline: Number(neg.deadline) || now + this.deadlineMs,
89
+ lastOffer: neg.lastOffer && neg.lastOffer.terms ? neg.lastOffer : null,
90
+ pendingReleaseAt: Number(neg.pendingReleaseAt) || 0,
91
+ pendingWakeAt: Number(neg.pendingWakeAt) || 0,
92
+ resolution: typeof neg.resolution === 'string' ? neg.resolution : '',
93
+ escalated: neg.escalated === true,
94
+ history: Array.isArray(neg.history) ? neg.history.slice(-this.maxHistory) : []
95
+ });
96
+ }
97
+ } catch (error) {
98
+ this.logger?.warn?.(`[session-messenger] negotiations load failed, starting empty: ${(error && error.message) || error}`);
99
+ } finally {
100
+ this.ready = true;
101
+ }
102
+ }
103
+
104
+ persist() {
105
+ return this.file.write(() => ({ version: 2, savedAt: Date.now(), negotiations: [...this.negotiations.values()] }));
106
+ }
107
+
108
+ /** M3:同一方在 rateMs 内只能发一次意图。返回剩余等待毫秒(0 = 放行并记账)。 */
109
+ rateLimited(negId, by, now) {
110
+ const k = `${negId}\u0000${by}`;
111
+ const last = this.rate.get(k) || 0;
112
+ if (now - last < this.rateMs) return this.rateMs - (now - last);
113
+ this.rate.set(k, now);
114
+ if (this.rate.size > 1024) {
115
+ for (const [key, at] of this.rate) if (now - at >= this.rateMs) this.rate.delete(key);
116
+ }
117
+ return 0;
118
+ }
119
+
120
+ /** 被状态机拒绝的意图退还额度,否则紧随其后的合法意图会被误伤。 */
121
+ refundRate(negId, by) {
122
+ this.rate.delete(`${negId}\u0000${by}`);
123
+ }
124
+
125
+ byId(id) {
126
+ return this.negotiations.get(String(id || '').trim());
127
+ }
128
+
129
+ findOpenForPair(key, a, b) {
130
+ const target = pairKey(key, a, b);
131
+ for (const neg of this.negotiations.values()) {
132
+ if (ACTIVE_STATES.has(neg.state) && pairKey(neg.key, neg.a, neg.b) === target) return neg;
133
+ }
134
+ return undefined;
135
+ }
136
+
137
+ /** 该文件上已约定、尚未执行的移交(accepted + pendingReleaseAt)。 */
138
+ pendingHandoff(key) {
139
+ for (const neg of this.negotiations.values()) {
140
+ if (neg.key === key && neg.state === 'accepted' && neg.pendingReleaseAt > 0 && neg.handoffTo) return neg;
141
+ }
142
+ return undefined;
143
+ }
144
+
145
+ /**
146
+ * 该文件上的等待队列(先来后到):非终态协商,以及已接受 wait-until、仍在等待的写入方。
147
+ * @param excludeId - 排除的协商(通常是刚完成移交的那一份)
148
+ */
149
+ queueFor(key, now, excludeId = '') {
150
+ return [...this.negotiations.values()]
151
+ .filter(
152
+ (neg) =>
153
+ neg.key === key &&
154
+ neg.id !== excludeId &&
155
+ (ACTIVE_STATES.has(neg.state) || (neg.state === 'accepted' && neg.pendingWakeAt > now && !neg.handoffTo))
156
+ )
157
+ .sort((x, y) => x.createdAt - y.createdAt || (x.id < y.id ? -1 : 1));
158
+ }
159
+
160
+ /** 1-based 排队位次(不在队列中返回 0)。 */
161
+ positionOf(neg, now) {
162
+ return this.queueFor(neg.key, now).findIndex((n) => n.id === neg.id) + 1;
163
+ }
164
+
165
+ /** 约定移交后,把同一文件上其余 open 协商转入 queued。返回被转入的协商。 */
166
+ queueOthers(neg, now) {
167
+ const moved = [];
168
+ for (const other of this.negotiations.values()) {
169
+ if (other.id === neg.id || other.key !== neg.key || other.state !== 'open') continue;
170
+ other.state = 'queued';
171
+ other.updatedAt = now;
172
+ other.history.push({ at: now, event: 'queued', behind: neg.writer });
173
+ moved.push(other);
174
+ }
175
+ return moved;
176
+ }
177
+
178
+ /** 移交完成后,把协商改绑到新占用方并重新开放(轮次、条款、期限全部重置)。 */
179
+ rebind(neg, holderId, holderLabel, now) {
180
+ const writer = neg.writer;
181
+ neg.holder = String(holderId);
182
+ neg.a = String(holderId);
183
+ neg.b = writer;
184
+ neg.labels = { ...(neg.labels || {}), [holderId]: holderLabel };
185
+ neg.state = 'open';
186
+ neg.rounds = 0;
187
+ neg.lastOffer = null;
188
+ neg.pendingWakeAt = 0;
189
+ neg.resolution = '';
190
+ neg.updatedAt = now;
191
+ neg.deadline = now + this.deadlineMs;
192
+ neg.history.push({ at: now, event: 'holder-changed', to: String(holderId) });
193
+ if (neg.history.length > this.maxHistory) neg.history.splice(0, neg.history.length - this.maxHistory);
194
+ }
195
+
196
+ /** L3:最近一次终态协商(冷静期内返回)。 */
197
+ recentTerminalForPair(key, a, b, now) {
198
+ const target = pairKey(key, a, b);
199
+ let best;
200
+ for (const neg of this.negotiations.values()) {
201
+ if (!TERMINAL_STATES.has(neg.state) || pairKey(neg.key, neg.a, neg.b) !== target) continue;
202
+ if (now - neg.updatedAt >= this.cooldownMs) continue;
203
+ if (!best || neg.updatedAt > best.updatedAt) best = neg;
204
+ }
205
+ return best;
206
+ }
207
+
208
+ /** 该会话参与的非终态协商(open / queued)。 */
209
+ openForSession(sessionId) {
210
+ return [...this.negotiations.values()].filter(
211
+ (neg) => ACTIVE_STATES.has(neg.state) && (neg.a === sessionId || neg.b === sessionId)
212
+ );
213
+ }
214
+
215
+ /**
216
+ * H1:路径对 sessionId 是否处于「争议冻结」。冻结条件(且持有方仍持有该 claim,由调用方
217
+ * 通过 holderHolds 判定——释放/过期后立即解冻,不必等看门狗):
218
+ * open / queued;冷静期内 escalated;accepted 且约定的移交/等待时刻未到。
219
+ * 持有方本人永不被冻结。返回冻结它的协商,否则 undefined。
220
+ */
221
+ frozenFor(key, sessionId, now, holderHolds) {
222
+ for (const neg of this.negotiations.values()) {
223
+ if (neg.key !== key || neg.holder === sessionId) continue;
224
+ const frozen =
225
+ ACTIVE_STATES.has(neg.state) ||
226
+ (neg.state === 'escalated' && now - neg.updatedAt < this.cooldownMs) ||
227
+ (neg.state === 'accepted' && (neg.pendingReleaseAt > now || neg.pendingWakeAt > now));
228
+ if (!frozen) continue;
229
+ if (holderHolds && !holderHolds(neg.holder, key)) continue;
230
+ return neg;
231
+ }
232
+ return undefined;
233
+ }
234
+
235
+ open({ id, path: filePath, key, a, b, labels, holder, writer, writerMeta, now }) {
236
+ const existing = this.findOpenForPair(key, a, b);
237
+ if (existing) return { neg: existing, created: false };
238
+ // 占用方已约定把该文件移交给别人:新写入方直接排队,不打扰占用方。
239
+ const pending = this.pendingHandoff(key);
240
+ const queued = !!pending && pending.holder === String(holder);
241
+ const neg = {
242
+ id,
243
+ path: filePath,
244
+ key,
245
+ a: String(a),
246
+ b: String(b),
247
+ labels: labels || {},
248
+ holder: String(holder || ''),
249
+ writer: String(writer || ''),
250
+ writerMeta: writerMeta && typeof writerMeta === 'object' ? writerMeta : {},
251
+ handoffTo: '',
252
+ state: queued ? 'queued' : 'open',
253
+ rounds: 0,
254
+ createdAt: now,
255
+ updatedAt: now,
256
+ deadline: now + this.deadlineMs,
257
+ lastOffer: null,
258
+ pendingReleaseAt: 0,
259
+ pendingWakeAt: 0,
260
+ resolution: '',
261
+ escalated: false,
262
+ history: []
263
+ };
264
+ this.negotiations.set(neg.id, neg);
265
+ return { neg, created: true };
266
+ }
267
+
268
+ touch(neg, entry, now) {
269
+ neg.history.push({ at: now, ...entry });
270
+ if (neg.history.length > this.maxHistory) neg.history.splice(0, neg.history.length - this.maxHistory);
271
+ neg.updatedAt = now;
272
+ neg.deadline = now + this.deadlineMs;
273
+ }
274
+
275
+ mark(neg, state, resolution, now) {
276
+ neg.state = state;
277
+ neg.resolution = resolution || '';
278
+ neg.updatedAt = now;
279
+ if (state === 'escalated') neg.escalated = true;
280
+ }
281
+
282
+ /** 状态机主入口:只返回决策与待执行副作用。 */
283
+ transition(neg, { by, action, terms, message, now }) {
284
+ if (!neg) return { ok: false, reason: 'negotiation not found' };
285
+ if (!ACTIVE_STATES.has(neg.state)) return { ok: false, reason: `negotiation is ${neg.state}` };
286
+ if (by !== neg.a && by !== neg.b) return { ok: false, reason: 'you are not a party of this negotiation' };
287
+ if (neg.state === 'queued' && action !== 'decline' && action !== 'escalate') {
288
+ const pending = this.pendingHandoff(neg.key);
289
+ const to = pending ? (pending.labels && pending.labels[pending.handoffTo]) || pending.handoffTo : 'the next holder';
290
+ return {
291
+ ok: false,
292
+ reason: `this negotiation is queued: the holder already agreed to hand the file to ${to}${pending ? ` at ${new Date(pending.pendingReleaseAt).toISOString()}` : ''}. Wait for the handoff notice (you will then negotiate with the new holder), or decline to leave the queue.`
293
+ };
294
+ }
295
+ const peerId = by === neg.a ? neg.b : neg.a;
296
+ const isHolder = by === neg.holder;
297
+ const label = (neg.labels && neg.labels[by]) || String(by).slice(0, 8);
298
+ const note = String(message || '').slice(0, 2000);
299
+
300
+ if (action === 'escalate' || action === 'decline') {
301
+ const state = action === 'escalate' ? 'escalated' : 'declined';
302
+ this.mark(neg, state, `${state} by ${label}`, now);
303
+ this.touch(neg, { by, action, message: note }, now);
304
+ return { ok: true, effects: { notifyPeer: peerId } };
305
+ }
306
+
307
+ if (action === 'offer' || action === 'counter') {
308
+ const normalized = normalizeTerms(terms);
309
+ if (!normalized) return { ok: false, reason: 'terms must be {action: release-now|release-at|wait-until, at?: ISO-8601}' };
310
+ if (RELEASE_ACTIONS.has(normalized.action) && !isHolder) {
311
+ return {
312
+ ok: false,
313
+ reason: 'only the current claim holder can offer release-now / release-at; as the writer offer {action:"wait-until", at} or decline'
314
+ };
315
+ }
316
+ if (RELEASE_ACTIONS.has(normalized.action) && this.pendingHandoff(neg.key)) {
317
+ return { ok: false, reason: 'you already agreed to hand this file to another session; it cannot be promised twice' };
318
+ }
319
+ if (normalized.action === 'wait-until' && isHolder) {
320
+ return { ok: false, reason: 'wait-until is the writer\'s commitment; as the holder offer release-now / release-at or decline' };
321
+ }
322
+ if (normalized.at !== undefined) {
323
+ if (normalized.at <= now) return { ok: false, reason: 'terms.at must be in the future' };
324
+ if (normalized.at > now + MAX_AHEAD_MS) return { ok: false, reason: 'terms.at must be within 60 minutes' };
325
+ }
326
+ if (neg.rounds + 1 > this.maxRounds) {
327
+ this.mark(neg, 'escalated', `round limit (${this.maxRounds}) reached`, now);
328
+ this.touch(neg, { by, action, terms: normalized, message: note }, now);
329
+ return { ok: true, effects: { notifyPeer: peerId, notifySelf: true, escalated: true } };
330
+ }
331
+ neg.rounds += 1;
332
+ neg.lastOffer = { by, terms: normalized, message: note };
333
+ this.touch(neg, { by, action, terms: normalized, message: note }, now);
334
+ return { ok: true, effects: { notifyPeer: peerId } };
335
+ }
336
+
337
+ if (action === 'accept') {
338
+ const offer = neg.lastOffer;
339
+ if (!offer || !offer.terms) return { ok: false, reason: 'there is no pending offer to accept' };
340
+ if (offer.by === by) return { ok: false, reason: 'you cannot accept your own offer; the other party must accept' };
341
+ const t = offer.terms;
342
+ if (t.at !== undefined && t.at <= now && t.action === 'wait-until') {
343
+ return { ok: false, reason: 'the offered wait-until time has already passed; ask for a new offer' };
344
+ }
345
+ if ((t.action === 'release-now' || t.action === 'release-at') && this.pendingHandoff(neg.key)) {
346
+ return { ok: false, reason: 'the holder already agreed to hand this file to another session' };
347
+ }
348
+ this.touch(neg, { by, action, message: note }, now);
349
+ const writerLabel = (neg.labels && neg.labels[neg.writer]) || String(neg.writer).slice(0, 8);
350
+ if (t.action === 'release-now' || (t.action === 'release-at' && t.at <= now)) {
351
+ this.mark(neg, 'accepted', `holder hands the file to ${writerLabel} now (accepted by ${label})`, now);
352
+ neg.handoffTo = neg.writer;
353
+ return { ok: true, effects: { handoff: true, releaseSessionId: neg.holder, releaseAt: now, notifyPeer: peerId } };
354
+ }
355
+ if (t.action === 'release-at') {
356
+ this.mark(neg, 'accepted', `holder hands the file to ${writerLabel} at ${new Date(t.at).toISOString()} (accepted by ${label})`, now);
357
+ neg.pendingReleaseAt = t.at;
358
+ neg.handoffTo = neg.writer;
359
+ return { ok: true, effects: { handoff: true, releaseSessionId: neg.holder, releaseAt: t.at, notifyPeer: peerId } };
360
+ }
361
+ this.mark(neg, 'accepted', `writer waits until ${new Date(t.at).toISOString()} (accepted by ${label})`, now);
362
+ neg.pendingWakeAt = t.at;
363
+ return { ok: true, effects: { wakeAt: t.at, notifyPeer: peerId } };
364
+ }
365
+
366
+ return { ok: false, reason: `unknown action "${action}"` };
367
+ }
368
+
369
+ /** 看门狗扫描:超时 → 升级;到点的 pending 释放/唤醒交回调用方执行。 */
370
+ scan(now) {
371
+ const escalate = [];
372
+ const dueReleases = [];
373
+ const dueWakes = [];
374
+ for (const neg of this.negotiations.values()) {
375
+ // queued 不走超时升级:它在等一次已约定的移交,而不是等对方回应。
376
+ if (neg.state === 'open' && now >= neg.deadline) {
377
+ this.mark(neg, 'escalated', 'no response before deadline', now);
378
+ escalate.push(neg);
379
+ continue;
380
+ }
381
+ if (neg.state !== 'accepted') continue;
382
+ if (neg.pendingReleaseAt && now >= neg.pendingReleaseAt) {
383
+ neg.pendingReleaseAt = 0;
384
+ neg.updatedAt = now;
385
+ dueReleases.push(neg);
386
+ }
387
+ if (neg.pendingWakeAt && now >= neg.pendingWakeAt) {
388
+ neg.pendingWakeAt = 0;
389
+ neg.updatedAt = now;
390
+ dueWakes.push(neg);
391
+ }
392
+ }
393
+ return { escalate, dueReleases, dueWakes };
394
+ }
395
+
396
+ /** M2:回收终态且静置超过 retentionMs、且没有待执行 pending 的记录。 */
397
+ prune(now, retentionMs = 30 * 60 * 1000) {
398
+ const removable = (neg) => TERMINAL_STATES.has(neg.state) && !neg.pendingReleaseAt && !neg.pendingWakeAt;
399
+ let removed = 0;
400
+ for (const [id, neg] of [...this.negotiations.entries()]) {
401
+ if (removable(neg) && now - neg.updatedAt > Math.max(retentionMs, this.cooldownMs)) {
402
+ this.negotiations.delete(id);
403
+ removed += 1;
404
+ }
405
+ }
406
+ if (this.negotiations.size > 200) {
407
+ const terminal = [...this.negotiations.values()].filter(removable).sort((x, y) => x.updatedAt - y.updatedAt);
408
+ for (const neg of terminal.slice(0, this.negotiations.size - 200)) {
409
+ this.negotiations.delete(neg.id);
410
+ removed += 1;
411
+ }
412
+ }
413
+ return removed;
414
+ }
415
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "dsh-session-messenger",
3
+ "version": "0.4.2",
4
+ "type": "module",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/XcodeFish/dsh-session-messenger.git"
9
+ },
10
+ "keywords": [
11
+ "dsh",
12
+ "cordis",
13
+ "plugin",
14
+ "multi-agent",
15
+ "session"
16
+ ],
17
+ "description": "Cross-session coordination for DSH: file-claim registry (manual claim_files plus auto-registration from fs write intents), multi-round structured negotiation (negotiate) with a dispute-scoped write freeze via tools.guard, a watchdog that escalates to the human and never auto-favours the writer, and rate-limited peer messaging (send_to_session) within one workspace.",
18
+ "exports": {
19
+ ".": "./index.js",
20
+ "./package.json": "./package.json",
21
+ "./locale/*.json": "./locale/*.json"
22
+ },
23
+ "files": [
24
+ "index.js",
25
+ "util.js",
26
+ "storage.js",
27
+ "registry.js",
28
+ "negotiation.js",
29
+ "delivery.js",
30
+ "coordinator.js",
31
+ "write-guard.js",
32
+ "tools.js",
33
+ "schemas.generated.js",
34
+ "locale/*.json",
35
+ "cordis.patch.yml"
36
+ ],
37
+ "scripts": {
38
+ "test": "node --test test/unit.test.mjs",
39
+ "check:schemas": "node extract-specs.mjs --check",
40
+ "probe": "node probe-cordis-load.mjs && SKIP_PROVIDER=1 node probe-cordis-load.mjs && node probe-l0-autoregister.mjs && node probe-negotiation.mjs"
41
+ },
42
+ "dsh": {
43
+ "bundle": { "patch": "./cordis.patch.yml" }
44
+ }
45
+ }
package/registry.js ADDED
@@ -0,0 +1,197 @@
1
+ /**
2
+ * ClaimRegistry — 文件占用登记表(内存真相 + 原子落盘)。
3
+ *
4
+ * 数据模型:键 = (sessionId, 路径身份键 key)。每条 claim:
5
+ * { sessionId, parent(父会话 id,子代理才有), label, cwd, path(展示用绝对路径), key(realpath 身份键),
6
+ * origin:'manual'|'auto', sessionOrigin:'session'|'subagent', claimedAt, expiresAt, note }
7
+ * - 同一家族(父会话与其子代理)之间不构成冲突,见 util.sameFamily。
8
+ *
9
+ * 语义要点(逐条对应审查结论):
10
+ * - H2:自动登记(写入意图)绝不削弱手动 claim——同会话已有 claim 时只把 expiresAt 延长到
11
+ * max(现有, now+autoTtl),保留原 note / origin / claimedAt;手动再 claim 同路径则升级为 manual。
12
+ * - All-or-nothing:任一路径被他人活 claim 占用 → 一条都不登记,返回冲突列表。
13
+ * - M4:唯一落盘入口 persist()(JsonFile 合并写),不存在两条互不感知的写路径。
14
+ * - M7:load() 完成前 ready=false;调用方必须在 ready 之后才对外提供工具。
15
+ * - 同步 API:所有变更在单 tick 内完成(Node 单线程),天然无交错;落盘异步合并。
16
+ */
17
+ import path from 'node:path';
18
+ import { JsonFile } from './storage.js';
19
+ import { pathKey, labelFor, isLegacyLabel, originOfHeader, sameFamily } from './util.js';
20
+
21
+ const FORMAT_VERSION = 2;
22
+ const keyOf = (sessionId, key) => `${sessionId}\u0000${key}`;
23
+
24
+ export class ClaimRegistry {
25
+ constructor({ dataDir, logger }) {
26
+ this.file = new JsonFile(path.join(dataDir, 'claims.json'));
27
+ this.logger = logger;
28
+ /** @type {Map<string, object>} */
29
+ this.claims = new Map();
30
+ this.ready = false;
31
+ }
32
+
33
+ async load(now) {
34
+ if (this.ready) return;
35
+ try {
36
+ const parsed = await this.file.read();
37
+ const list = parsed && Array.isArray(parsed.claims) ? parsed.claims : [];
38
+ for (const c of list) {
39
+ if (!c || typeof c.path !== 'string' || typeof c.sessionId !== 'string' || !path.isAbsolute(c.path)) continue;
40
+ const expiresAt = Number(c.expiresAt) || 0;
41
+ if (expiresAt <= now) continue;
42
+ const key = typeof c.key === 'string' && c.key ? c.key : pathKey(c.path);
43
+ const legacyAuto = String(c.note || '').startsWith('auto:');
44
+ const origin = c.origin === 'manual' || c.origin === 'auto' ? c.origin : legacyAuto ? 'auto' : 'manual';
45
+ this.claims.set(keyOf(c.sessionId, key), {
46
+ sessionId: c.sessionId,
47
+ parent: typeof c.parent === 'string' ? c.parent : '',
48
+ label: isLegacyLabel(c.label) ? labelFor(c.sessionId, c.cwd) : c.label,
49
+ cwd: typeof c.cwd === 'string' ? c.cwd : '',
50
+ path: c.path,
51
+ key,
52
+ origin,
53
+ sessionOrigin:
54
+ c.sessionOrigin === 'subagent' || c.sessionOrigin === 'session' ? c.sessionOrigin : originOfHeader(undefined, c.sessionId),
55
+ claimedAt: Number(c.claimedAt) || now,
56
+ expiresAt,
57
+ note: typeof c.note === 'string' ? c.note : ''
58
+ });
59
+ }
60
+ } catch (error) {
61
+ this.logger?.warn?.(`[session-messenger] claims load failed, starting empty: ${(error && error.message) || error}`);
62
+ } finally {
63
+ this.ready = true;
64
+ }
65
+ }
66
+
67
+ persist() {
68
+ return this.file.write(() => ({ version: FORMAT_VERSION, savedAt: Date.now(), claims: [...this.claims.values()] }));
69
+ }
70
+
71
+ prune(now) {
72
+ let removed = 0;
73
+ for (const [k, claim] of this.claims) {
74
+ if (claim.expiresAt <= now) {
75
+ this.claims.delete(k);
76
+ removed += 1;
77
+ }
78
+ }
79
+ return removed;
80
+ }
81
+
82
+ listLive(now) {
83
+ this.prune(now);
84
+ return [...this.claims.values()];
85
+ }
86
+
87
+ /** 他人(非本会话、非同家族)在该身份键上的活 claim;manual 优先、到期晚者优先。 */
88
+ othersOn(key, sessionId, now, parent = '') {
89
+ return this.listLive(now)
90
+ .filter((c) => c.key === key && !sameFamily(sessionId, parent, c.sessionId, c.parent))
91
+ .sort((x, y) => (x.origin === y.origin ? y.expiresAt - x.expiresAt : x.origin === 'manual' ? -1 : 1));
92
+ }
93
+
94
+ own(sessionId, key) {
95
+ return this.claims.get(keyOf(sessionId, key));
96
+ }
97
+
98
+ /**
99
+ * All-or-nothing 登记。冲突语义(v0.4.1):
100
+ * - 只有他人的**手动** claim(明确声明的意图)构成冲突 → 一条都不登记,返回 conflicts。
101
+ * - 他人的**自动** claim(只是最近写过)不构成冲突:照常登记,返回 overlaps 供调用方做
102
+ * 低优先级提示——否则「顺手改过一次」就会锁住别人(v0.4.0 真机前复现的误冻结)。
103
+ * @param entries - [{ path, key }]
104
+ * @param origin - 'manual'(claim_files)| 'auto'(写入意图)
105
+ */
106
+ claim({ sessionId, parent = '', label, cwd, sessionOrigin, entries, ttlSeconds, note, origin, now }) {
107
+ this.prune(now);
108
+ const conflicts = [];
109
+ const overlaps = [];
110
+ for (const entry of entries) {
111
+ for (const c of this.othersOn(entry.key, sessionId, now, parent)) {
112
+ const row = {
113
+ path: entry.path,
114
+ ownerSessionId: c.sessionId,
115
+ ownerLabel: c.label || c.sessionId,
116
+ ownerOrigin: c.origin,
117
+ expiresAt: c.expiresAt,
118
+ note: c.note || ''
119
+ };
120
+ (c.origin === 'manual' ? conflicts : overlaps).push(row);
121
+ }
122
+ }
123
+ if (conflicts.length > 0) return { registered: false, conflicts, overlaps, expiresAt: 0 };
124
+ const target = now + ttlSeconds * 1000;
125
+ let expiresAt = target;
126
+ for (const entry of entries) {
127
+ const k = keyOf(sessionId, entry.key);
128
+ const existing = this.claims.get(k);
129
+ if (existing && origin === 'auto') {
130
+ // H2:自动登记只延长,不降级、不覆盖 note/origin。
131
+ existing.expiresAt = Math.max(existing.expiresAt, target);
132
+ if (isLegacyLabel(existing.label) && label) existing.label = label;
133
+ expiresAt = Math.min(expiresAt, existing.expiresAt);
134
+ continue;
135
+ }
136
+ this.claims.set(k, {
137
+ sessionId,
138
+ parent: parent || '',
139
+ label: label || sessionId,
140
+ cwd: cwd || '',
141
+ path: entry.path,
142
+ key: entry.key,
143
+ origin,
144
+ sessionOrigin: sessionOrigin === 'subagent' ? 'subagent' : 'session',
145
+ claimedAt: existing ? existing.claimedAt : now,
146
+ expiresAt: target,
147
+ note: note || (existing && existing.origin === 'manual' ? existing.note : '') || ''
148
+ });
149
+ }
150
+ return { registered: true, conflicts: [], overlaps, expiresAt };
151
+ }
152
+
153
+ /** 该会话在该路径上是否持有未过期的**手动** claim(协商与冻结的唯一依据)。 */
154
+ holdsManual(sessionId, key, now) {
155
+ const own = this.claims.get(keyOf(sessionId, key));
156
+ return !!own && own.origin === 'manual' && own.expiresAt > now;
157
+ }
158
+
159
+ /**
160
+ * 原子移交(v0.4.2):把 fromId 在 key 上的手动 claim 直接交给 to(不经「先释放再抢」)。
161
+ * 新 claim 为 manual、TTL 取 max(原剩余, 默认);同时删除接收方在该路径上已有的 auto claim。
162
+ * @returns 新 claim;fromId 没有该 claim 时返回 undefined(调用方按「路径已空」处理)
163
+ */
164
+ transfer(fromId, key, to, now, ttlSeconds = 1800) {
165
+ const src = this.claims.get(keyOf(fromId, key));
166
+ if (!src || src.expiresAt <= now) return undefined;
167
+ this.claims.delete(keyOf(fromId, key));
168
+ const next = {
169
+ sessionId: String(to.sessionId),
170
+ parent: to.parent || '',
171
+ label: to.label || String(to.sessionId),
172
+ cwd: to.cwd || '',
173
+ path: src.path,
174
+ key,
175
+ origin: 'manual',
176
+ sessionOrigin: to.sessionOrigin === 'subagent' ? 'subagent' : 'session',
177
+ claimedAt: now,
178
+ expiresAt: Math.max(src.expiresAt, now + ttlSeconds * 1000),
179
+ note: `handed over by ${src.label || src.sessionId}${src.note ? ` (was: ${src.note.slice(0, 120)})` : ''}`
180
+ };
181
+ this.claims.set(keyOf(next.sessionId, key), next);
182
+ return next;
183
+ }
184
+
185
+ /** 释放本会话的 claim:keys 为 null 时全部释放。返回释放条数。 */
186
+ release(sessionId, keys) {
187
+ let count = 0;
188
+ const wanted = keys ? new Set(keys) : null;
189
+ for (const [k, claim] of [...this.claims.entries()]) {
190
+ if (claim.sessionId !== sessionId) continue;
191
+ if (wanted && !wanted.has(claim.key)) continue;
192
+ this.claims.delete(k);
193
+ count += 1;
194
+ }
195
+ return count;
196
+ }
197
+ }