@runko/persist-mongo 0.1.1 → 0.2.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/dist/index.cjs CHANGED
@@ -3,6 +3,9 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
3
  const LEDGER_COLLECTION = "agent_ledger";
4
4
  const DECISIONS_COLLECTION = "agent_decisions";
5
5
  const QUEUE_COLLECTION = "agent_queue";
6
+ const LEASES_COLLECTION = "agent_leases";
7
+ const NODES_COLLECTION = "agent_nodes";
8
+ const TAILS_COLLECTION = "agent_tool_tails";
6
9
  /**
7
10
  * **写进 BSON 之前先过一遍 JSON。**
8
11
  *
@@ -44,12 +47,33 @@ function fromBson(value) {
44
47
  return value;
45
48
  }
46
49
  //#endregion
47
- //#region src/stores.ts
48
- const OK = { ok: true };
49
- /** 重复键。并发 upsert 撞同一个唯一索引时驱动抛这个,见 `upsertOnce`。 */
50
+ //#region src/errors.ts
51
+ /**
52
+ * 认 MongoDB 驱动抛出来的两类错误。
53
+ *
54
+ * **它们是本包里唯一需要「看错误内部」的地方**,所以收在一个文件里:驱动抛的是
55
+ * `MongoServerError`,但 `instanceof` 在跨 driver 实例(宿主装的那份 vs 我们看到的那份)
56
+ * 时不可靠,判 `code` 才稳。
57
+ *
58
+ * **一个断言都没有**:`typeof x === "object"` + `x !== null` + `"code" in x` 三步之后,
59
+ * TypeScript 已经把它收窄成「有 `code` 这个键的对象」,直接读就行(`in` 的收窄行为从
60
+ * TS 4.9 起就有了)。这正是仓库规范要的写法——用类型守卫,不用 `as`。
61
+ */
62
+ /** 重复键。唯一索引挡下并发插入 / upsert 时驱动抛这个。 */
50
63
  const DUPLICATE_KEY = 11e3;
64
+ /** 同名索引已存在但选项或键不同时 Mongo 报的两个 code。 */
65
+ const INDEX_CONFLICT_CODES = /* @__PURE__ */ new Set([85, 86]);
66
+ /** 取出 `error.code`,拿不到就 `undefined`。 */
67
+ function codeOf(error) {
68
+ if (typeof error !== "object" || error === null || !("code" in error)) return;
69
+ return error.code;
70
+ }
51
71
  function isDuplicateKeyError(error) {
52
- return typeof error === "object" && error !== null && "code" in error && error.code === DUPLICATE_KEY;
72
+ return codeOf(error) === DUPLICATE_KEY;
73
+ }
74
+ function isIndexConflict(error) {
75
+ const code = codeOf(error);
76
+ return typeof code === "number" && INDEX_CONFLICT_CODES.has(code);
53
77
  }
54
78
  /**
55
79
  * 幂等插入的**兜底那一半**:吞掉重复键,其余错误照抛。
@@ -61,9 +85,7 @@ function isDuplicateKeyError(error) {
61
85
  * 挡下后一个。这是 MongoDB 明确记录的行为,不是 bug。那一支的结果照样是「已经有了
62
86
  * 一行」——契约要的是「不得写出两行」,满足了,所以吞掉。
63
87
  *
64
- * **只包异常处理、不包 `updateOne` 本身**是刻意的:这样 `$setOnInsert` 的字段能在
65
- * **具体的调用点**跟具体的文档类型对上,写错字段当场编译不过。包成一个泛型 helper
66
- * 反而会把类型冲开、逼出一个 `as`。
88
+ * 收在这里(而不是各自 store 一份)是因为 `stores.ts` 与 `tails.ts` 都要用它。
67
89
  */
68
90
  async function ignoringDuplicateKey(write) {
69
91
  try {
@@ -73,6 +95,9 @@ async function ignoringDuplicateKey(write) {
73
95
  return;
74
96
  }
75
97
  }
98
+ //#endregion
99
+ //#region src/stores.ts
100
+ const OK$1 = { ok: true };
76
101
  function createLedgerStore(db) {
77
102
  const col = db.collection(LEDGER_COLLECTION);
78
103
  return {
@@ -83,12 +108,12 @@ function createLedgerStore(db) {
83
108
  }, { $setOnInsert: {
84
109
  payload: toBson(entry.message),
85
110
  ts: entry.ts
86
- } }, { upsert: true })))?.upsertedCount === 1) return OK;
111
+ } }, { upsert: true })))?.upsertedCount === 1) return OK$1;
87
112
  const existing = await col.findOne({
88
113
  conversationId: entry.conversationId,
89
114
  seq: entry.seq
90
115
  }, { projection: { _id: 0 } });
91
- if (existing !== null && fromBson(existing.payload).id === entry.message.id) return OK;
116
+ if (existing !== null && fromBson(existing.payload).id === entry.message.id) return OK$1;
92
117
  return {
93
118
  ok: false,
94
119
  reason: "rejected"
@@ -120,8 +145,15 @@ function createLedgerStore(db) {
120
145
  function isOutcome(value) {
121
146
  return value === "allow" || value === "deny" || value === "answered" || value === "timeout";
122
147
  }
123
- /** 可选字段一律「有才放进去」——`exactOptionalPropertyTypes` 下不能给 `undefined`。 */
124
- function toRecord(doc) {
148
+ /**
149
+ * 可选字段一律「有才放进去」。
150
+ *
151
+ * 这个函数是在**两种「没有」之间翻译**:库里用 `null` 表示没有,`DecisionRecord` 用
152
+ * 「键不存在」表示没有。写成 `toolName: doc.toolName ?? undefined` 会造出第三种形状
153
+ * ——键在、值是 `undefined`——它既不是库那边的表示、也不是接口这边的表示,而且
154
+ * `"toolName" in record` 会答 `true`,把「没有」说成「有」。
155
+ */
156
+ function toRecord$1(doc) {
125
157
  return {
126
158
  conversationId: doc.conversationId,
127
159
  toolCallId: doc.toolCallId,
@@ -154,7 +186,7 @@ function createDecisionStore(db) {
154
186
  requestedAt: entry.requestedAt,
155
187
  decidedAt: entry.decidedAt ?? null
156
188
  } }, { upsert: true }));
157
- return OK;
189
+ return OK$1;
158
190
  },
159
191
  async settle(conversationId, toolCallId, settlement) {
160
192
  return (await col.updateOne({
@@ -173,7 +205,14 @@ function createDecisionStore(db) {
173
205
  return (await col.find({
174
206
  conversationId,
175
207
  decidedAt: null
176
- }, { projection: { _id: 0 } }).sort({ requestedAt: 1 }).toArray()).map(toRecord);
208
+ }, { projection: { _id: 0 } }).sort({ requestedAt: 1 }).toArray()).map(toRecord$1);
209
+ },
210
+ async get(conversationId, toolCallId) {
211
+ const doc = await col.findOne({
212
+ conversationId,
213
+ toolCallId
214
+ }, { projection: { _id: 0 } });
215
+ return doc === null ? void 0 : toRecord$1(doc);
177
216
  }
178
217
  };
179
218
  }
@@ -292,12 +331,574 @@ function createQueueStore(db) {
292
331
  };
293
332
  }
294
333
  //#endregion
295
- //#region src/migrate.ts
296
- /** 同名索引已存在但选项不同时 Mongo 报的两个 code。 */
297
- const INDEX_CONFLICT_CODES = /* @__PURE__ */ new Set([85, 86]);
298
- function isIndexConflict(error) {
299
- return typeof error === "object" && error !== null && "code" in error && typeof error.code === "number" && INDEX_CONFLICT_CODES.has(error.code);
334
+ //#region src/tails.ts
335
+ const OK = { ok: true };
336
+ /**
337
+ * `JsonValue` 深比较,与对象键的排列顺序无关——MongoDB 的驱动/序列化过程不保证
338
+ * 写入与读回时对象键的顺序一致,`JSON.stringify` 直接比较会把「同一个对象、键序不同」
339
+ * 误判成不相等。
340
+ */
341
+ function jsonValueEquals(a, b) {
342
+ if (a === b) return true;
343
+ if (Array.isArray(a) || Array.isArray(b)) {
344
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
345
+ return a.every((item, index) => {
346
+ const other = b.at(index);
347
+ return other !== void 0 && jsonValueEquals(item, other);
348
+ });
349
+ }
350
+ if (typeof a === "object" && a !== null && typeof b === "object" && b !== null) {
351
+ const aEntries = Object.entries(a);
352
+ const bKeys = Object.keys(b);
353
+ if (aEntries.length !== bKeys.length) return false;
354
+ return aEntries.every(([key, value]) => {
355
+ const other = b[key];
356
+ return other !== void 0 && jsonValueEquals(value, other);
357
+ });
358
+ }
359
+ return false;
360
+ }
361
+ /** 结构相等:用来判断「读回来的那条是不是我刚写的那条」(见 `complete` 的读回确认)。 */
362
+ function outcomeEquals(a, b) {
363
+ if (a.kind !== b.kind) return false;
364
+ if (a.kind === "output" && b.kind === "output") return jsonValueEquals(a.output, b.output);
365
+ if (a.kind === "error" && b.kind === "error") return a.errorText === b.errorText;
366
+ return false;
367
+ }
368
+ function toRecord(doc) {
369
+ return {
370
+ conversationId: doc.conversationId,
371
+ toolCallId: doc.toolCallId,
372
+ toolName: doc.toolName,
373
+ runner: doc.runner,
374
+ startedAt: doc.startedAt,
375
+ deadline: doc.deadline,
376
+ ...doc.outcome !== null ? { outcome: fromBson(doc.outcome) } : {},
377
+ ...doc.settledAt !== null ? { settledAt: doc.settledAt } : {},
378
+ stopRequested: doc.stopRequested
379
+ };
380
+ }
381
+ function createToolTailStore(db) {
382
+ const col = db.collection(TAILS_COLLECTION);
383
+ return {
384
+ async begin(record) {
385
+ await ignoringDuplicateKey(() => col.updateOne({
386
+ conversationId: record.conversationId,
387
+ toolCallId: record.toolCallId
388
+ }, { $setOnInsert: {
389
+ toolName: record.toolName,
390
+ runner: record.runner,
391
+ startedAt: record.startedAt,
392
+ deadline: record.deadline,
393
+ outcome: null,
394
+ settledAt: null,
395
+ stopRequested: false
396
+ } }, { upsert: true }));
397
+ return OK;
398
+ },
399
+ async complete(conversationId, toolCallId, outcome, settledAt) {
400
+ await col.updateOne({
401
+ conversationId,
402
+ toolCallId,
403
+ outcome: null
404
+ }, { $set: {
405
+ outcome: toBson(outcome),
406
+ settledAt
407
+ } });
408
+ const doc = await col.findOne({
409
+ conversationId,
410
+ toolCallId
411
+ }, { projection: {
412
+ outcome: 1,
413
+ settledAt: 1
414
+ } });
415
+ if (doc === null || doc.outcome === null) return false;
416
+ return outcomeEquals(fromBson(doc.outcome), outcome) && doc.settledAt === settledAt;
417
+ },
418
+ async get(conversationId, toolCallId) {
419
+ const doc = await col.findOne({
420
+ conversationId,
421
+ toolCallId
422
+ }, { projection: { _id: 0 } });
423
+ return doc === null ? void 0 : toRecord(doc);
424
+ },
425
+ async requestStop(conversationId, toolCallId) {
426
+ await col.updateOne({
427
+ conversationId,
428
+ toolCallId,
429
+ outcome: null
430
+ }, { $set: { stopRequested: true } });
431
+ const doc = await col.findOne({
432
+ conversationId,
433
+ toolCallId
434
+ }, { projection: {
435
+ outcome: 1,
436
+ stopRequested: 1
437
+ } });
438
+ return doc !== null && doc.outcome === null && doc.stopRequested === true;
439
+ }
440
+ };
441
+ }
442
+ //#endregion
443
+ //#region src/arbitration.ts
444
+ /** 心跳间隔的默认值(毫秒)。与 SQL 那一档同款,定案见技术方案 §8.3。 */
445
+ const DEFAULT_HEARTBEAT_MS = 5e3;
446
+ /** 超时接管阈值的默认值(毫秒)——12 个心跳。 */
447
+ const DEFAULT_TAKEOVER_MS = 6e4;
448
+ /** 阈值至少要是心跳的几倍。配成 2× 会让一次正常的调度延迟就触发误判。 */
449
+ const MIN_BEATS_BEFORE_TAKEOVER = 3;
450
+ /**
451
+ * 建一个租约版归属仲裁机制。
452
+ *
453
+ * ```ts
454
+ * const arbitration = mongoArbitration(db, { holder: process.env.POD_NAME });
455
+ * createAgentRuntime({ ..., persistence: mongoPersistence(db), arbitration });
456
+ * ```
457
+ *
458
+ * **吃的是同一个 `Db`**——跟 `mongoPersistence(db)` 一样。`migrate(db)` 已经把租约集合要的
459
+ * 索引一起建了,不用再调别的。
460
+ */
461
+ function mongoArbitration(db, opts) {
462
+ const heartbeatMs = opts.heartbeatMs ?? 5e3;
463
+ const takeoverMs = opts.takeoverMs ?? 6e4;
464
+ const now = opts.now ?? Date.now;
465
+ if (takeoverMs < heartbeatMs * MIN_BEATS_BEFORE_TAKEOVER) throw new Error(`mongoArbitration: takeoverMs (${String(takeoverMs)}ms) must be at least ${String(MIN_BEATS_BEFORE_TAKEOVER)}× heartbeatMs (${String(heartbeatMs)}ms). A shorter threshold turns one ordinary scheduling delay into two simultaneous holders, which corrupts the ledger silently.`);
466
+ const col = db.collection(LEASES_COLLECTION);
467
+ /**
468
+ * 本进程起来的时刻。
469
+ *
470
+ * **挂在自己名下、心跳却早于这一刻的租约,一定是上一辈子留下的**——一个刚起来的进程
471
+ * 不可能已经在跑任何一轮。启动扫描据此立刻收拾它,不必干等[接管阈值](../../../docs/terms.md):
472
+ * 崩溃后马上重启的会话,重启完就能用。
473
+ *
474
+ * **前提是每个进程的 `holder` 唯一。** 两个活着的进程配成同一个名字时,后起来的那个会把
475
+ * 先来的那一轮抢过去;先来的手里那个[租期标识](../../../docs/terms.md)已经作废、写不进账本,
476
+ * 所以坏的是那一轮,不是数据。
477
+ */
478
+ const startedAt = now();
479
+ /** 挂在我名下、心跳早于本进程启动 = 上一辈子的残留。 */
480
+ const isMyOrphan = (doc) => doc.holder === opts.holder && doc.heartbeatAt < startedAt;
481
+ /**
482
+ * 陈旧判据:心跳超过接管阈值没续,**或者**是我自己上一辈子的残留。
483
+ *
484
+ * `listStale`、`clearStale`、`acquire` 三处共用这一条——判据一旦不一致,`recover()` 的
485
+ * `listStale → clearStale → acquire` 会互相打架(见 `clearStale` 的注释)。
486
+ */
487
+ const staleOr = (at) => [{ heartbeatAt: { $lt: at - takeoverMs } }, {
488
+ holder: opts.holder,
489
+ heartbeatAt: { $lt: startedAt }
490
+ }];
491
+ /** 一条租约是不是「有人持有且还活着」。我自己上一辈子的残留不算活着。 */
492
+ const isLive = (doc, at) => doc.leaseToken !== null && at - doc.heartbeatAt <= takeoverMs && !isMyOrphan(doc);
493
+ /**
494
+ * [交接预留](../../../docs/terms.md)还有效吗——`reservedUntil` 缺席或已过 = 没有预留,
495
+ * 退化成「没人持有」。返回预留给谁,`undefined` = 没有有效预留。
496
+ */
497
+ const activeReservedFor = (doc, at) => {
498
+ if (doc === null || doc.reservedFor === null || doc.reservedUntil === null) return;
499
+ return doc.reservedUntil > at ? doc.reservedFor : void 0;
500
+ };
501
+ async function acquire(conversationId, ctx) {
502
+ const at = now();
503
+ const existing = await col.findOne({ _id: conversationId });
504
+ const reservedFor = activeReservedFor(existing, at);
505
+ if (reservedFor !== void 0 && reservedFor !== opts.holder) return {
506
+ ok: false,
507
+ reason: "busy",
508
+ holder: reservedFor
509
+ };
510
+ if (existing !== null && isLive(existing, at)) return {
511
+ ok: false,
512
+ reason: "busy",
513
+ holder: existing.holder ?? void 0
514
+ };
515
+ const token = crypto.randomUUID();
516
+ if (existing === null) {
517
+ const watermark = await ctx.seedSeq();
518
+ try {
519
+ await col.insertOne({
520
+ _id: conversationId,
521
+ holder: null,
522
+ leaseToken: null,
523
+ seqWatermark: watermark,
524
+ heartbeatAt: 0,
525
+ acquiredAt: at,
526
+ reservedFor: null,
527
+ reservedUntil: null,
528
+ awaitingTakeover: false
529
+ });
530
+ } catch (error) {
531
+ if (!isDuplicateKeyError(error)) throw error;
532
+ }
533
+ } else if (existing.seqWatermark === null) {
534
+ const watermark = await ctx.seedSeq();
535
+ await col.updateOne({
536
+ _id: conversationId,
537
+ seqWatermark: null
538
+ }, { $set: { seqWatermark: watermark } });
539
+ }
540
+ if (await col.findOneAndUpdate({
541
+ _id: conversationId,
542
+ $or: [{ leaseToken: null }, ...staleOr(at)],
543
+ $and: [{ $or: [
544
+ { reservedFor: null },
545
+ { reservedFor: opts.holder },
546
+ { reservedUntil: { $lte: at } }
547
+ ] }]
548
+ }, { $set: {
549
+ holder: opts.holder,
550
+ leaseToken: token,
551
+ heartbeatAt: at,
552
+ acquiredAt: at,
553
+ reservedFor: null,
554
+ reservedUntil: null
555
+ } }, { returnDocument: "after" }) === null) {
556
+ const current = await col.findOne({ _id: conversationId });
557
+ const blockedBy = activeReservedFor(current, now());
558
+ if (blockedBy !== void 0 && blockedBy !== opts.holder) return {
559
+ ok: false,
560
+ reason: "busy",
561
+ holder: blockedBy
562
+ };
563
+ return {
564
+ ok: false,
565
+ reason: "busy",
566
+ holder: current?.holder ?? void 0
567
+ };
568
+ }
569
+ const grant = createGrant(conversationId, token, at);
570
+ if (existing !== null && existing.leaseToken !== null) return {
571
+ ok: true,
572
+ grant,
573
+ takeover: existing.holder !== null ? { holder: existing.holder } : {}
574
+ };
575
+ return {
576
+ ok: true,
577
+ grant
578
+ };
579
+ }
580
+ function createGrant(conversationId, token, acquiredAt) {
581
+ const controller = new AbortController();
582
+ let released = false;
583
+ /** 取号串起来跑:同一个 grant 的两次 `nextSeq` 交错会读到同一个水位。 */
584
+ let chain = Promise.resolve();
585
+ /**
586
+ * 最后一次成功心跳**写进 `heartbeatAt` 的那个时刻**——不是往返回来之后重新取的
587
+ * `now()`。别的副本判死看的就是库里那个值,围栏必须拿同一个基准算余量;用 ack
588
+ * 之后的时刻会把整条往返白送出去,往返一旦接近心跳间隔(库劣化时很常见,而库劣化
589
+ * 正是这段代码存在的理由)余量就归零甚至变负——收手比别人接管还晚。
590
+ */
591
+ let lastBeatAt = acquiredAt;
592
+ /** 上一拍还没跑完就别再起一拍(库卡住时 `beat` 可能比间隔还慢)。 */
593
+ let beating = false;
594
+ /**
595
+ * 别人最早能在 `lastBeatAt + takeoverMs` 接管我,所以留一拍的余量提前收手。
596
+ * 这条依赖各副本时钟大致同步;时钟不可信的部署应该把 `takeoverMs` 调大,
597
+ * 而不是把这个余量调小。
598
+ */
599
+ const fenceExpired = () => now() - lastBeatAt >= takeoverMs - heartbeatMs;
600
+ /**
601
+ * 心跳。**它同时是「我还持有吗」的主要发现路径**——比 `nextSeq` 更早,因为一轮里
602
+ * 可能很久才写一次账本。打不中就说明令牌被换了:立刻 abort,让正在跑的那一轮走
603
+ * 既有的中断收尾。
604
+ *
605
+ * **失败要分两档处理,这是这段最容易写错的地方**:
606
+ *
607
+ * - 偶发一两次失败 → 什么都不做,下一拍再试(网络抖动不该中断用户的一轮)。
608
+ * - **连续失败到逼近接管阈值 → 自己主动停手**(自我围栏)。不这么做的话:库连不上
609
+ * 恰恰是副本停跳最常见的原因,那时我的心跳每拍都失败、永远不 abort,而别人在
610
+ * `takeoverMs` 之后已经合法接管并开始跑——账本有令牌 CAS 挡着不会写坏,但
611
+ * **沙盒挡不住**,两个执行同时改同一个工作区就是互相踩文件。
612
+ *
613
+ * **围栏判断必须放在最前面**,在防重入早退之前。心跳失败有两种形状:抛错(连不上)
614
+ * 与**挂住不返回**(TCP 黑洞、连接池耗尽、网络分区下的 TCP 停滞)——后者更常见,
615
+ * 而且**不会进 `catch`**,它只是让上一拍永远不结束。围栏判断若只写在 `catch` 里,
616
+ * `beating` 会永久停在 `true`,此后每一拍都在防重入这里就 return 了,于是老持有者
617
+ * 永远不停手,而别人早已合法接管。
618
+ */
619
+ const beat = async () => {
620
+ if (released || controller.signal.aborted) return;
621
+ if (fenceExpired()) {
622
+ lose();
623
+ return;
624
+ }
625
+ if (beating) return;
626
+ beating = true;
627
+ const beatAt = now();
628
+ try {
629
+ if ((await col.updateOne({
630
+ _id: conversationId,
631
+ leaseToken: token
632
+ }, { $set: { heartbeatAt: beatAt } })).matchedCount > 0) {
633
+ lastBeatAt = beatAt;
634
+ return;
635
+ }
636
+ } catch {
637
+ return;
638
+ } finally {
639
+ beating = false;
640
+ }
641
+ lose();
642
+ };
643
+ const timer = setInterval(() => {
644
+ beat();
645
+ }, heartbeatMs);
646
+ if (typeof timer.unref === "function") timer.unref();
647
+ function lose() {
648
+ if (controller.signal.aborted) return;
649
+ clearInterval(timer);
650
+ controller.abort(/* @__PURE__ */ new Error("lease lost"));
651
+ }
652
+ return {
653
+ conversationId,
654
+ holder: opts.holder,
655
+ signal: controller.signal,
656
+ get valid() {
657
+ return !released && !controller.signal.aborted;
658
+ },
659
+ async nextSeq() {
660
+ const run = chain.then(async () => {
661
+ if (released || controller.signal.aborted) return {
662
+ ok: false,
663
+ reason: "lost_ownership"
664
+ };
665
+ try {
666
+ const beatAt = now();
667
+ const doc = await col.findOneAndUpdate({
668
+ _id: conversationId,
669
+ leaseToken: token
670
+ }, {
671
+ $inc: { seqWatermark: 1 },
672
+ $set: { heartbeatAt: beatAt }
673
+ }, { returnDocument: "after" });
674
+ if (doc === null || doc.seqWatermark === null) {
675
+ lose();
676
+ return {
677
+ ok: false,
678
+ reason: "lost_ownership"
679
+ };
680
+ }
681
+ lastBeatAt = beatAt;
682
+ return {
683
+ ok: true,
684
+ seq: doc.seqWatermark
685
+ };
686
+ } catch {
687
+ return {
688
+ ok: false,
689
+ reason: "lost_ownership"
690
+ };
691
+ }
692
+ });
693
+ chain = run.catch(() => void 0);
694
+ return await run;
695
+ },
696
+ async release() {
697
+ if (released) return;
698
+ released = true;
699
+ clearInterval(timer);
700
+ await col.updateOne({
701
+ _id: conversationId,
702
+ leaseToken: token
703
+ }, { $set: {
704
+ holder: null,
705
+ leaseToken: null
706
+ } });
707
+ },
708
+ async releaseTo(node, handoverOpts) {
709
+ if (released) return;
710
+ released = true;
711
+ clearInterval(timer);
712
+ const at = now();
713
+ await col.updateOne({
714
+ _id: conversationId,
715
+ leaseToken: token
716
+ }, { $set: {
717
+ holder: null,
718
+ leaseToken: null,
719
+ reservedFor: node,
720
+ reservedUntil: at + handoverOpts.ttlMs
721
+ } });
722
+ }
723
+ };
724
+ }
725
+ return {
726
+ acquire,
727
+ async inspect(conversationId) {
728
+ const at = now();
729
+ const doc = await col.findOne({ _id: conversationId });
730
+ if (doc !== null && isLive(doc, at)) return {
731
+ held: true,
732
+ ...doc.holder !== null ? { holder: doc.holder } : {}
733
+ };
734
+ const reservedFor = activeReservedFor(doc, at);
735
+ if (reservedFor !== void 0) return {
736
+ held: true,
737
+ holder: reservedFor,
738
+ reserved: true
739
+ };
740
+ return { held: false };
741
+ },
742
+ /**
743
+ * 「有人持有但心跳已超时」的那些会话。
744
+ *
745
+ * **语义跟内存版不一样,但两个都对**:内存版的归属表跟进程同生共死,看不到自己上次
746
+ * 崩溃的残留(恒返回空数组);租约版看得到——那正是它存在的理由。
747
+ */
748
+ async listStale() {
749
+ return (await col.find({
750
+ leaseToken: { $ne: null },
751
+ $or: staleOr(now())
752
+ }, { projection: { holder: 1 } }).toArray()).map((doc) => ({
753
+ conversationId: doc._id,
754
+ ...doc.holder !== null ? { holder: doc.holder } : {}
755
+ }));
756
+ },
757
+ /**
758
+ * 清一条陈旧标记。**必须带上跟 `listStale` 同一条陈旧判据**,不能只按会话清——
759
+ * 否则会擦掉一个**活着的**持有者的租约。
760
+ *
761
+ * 触发路径就在框架自己身上:`recover()` 的顺序是 `listStale → clearStale → acquire`。
762
+ * 多副本滚动重启时两个副本同时开机、扫到同一条陈旧会话:A 先清完并抢到令牌开始补写,
763
+ * B 随后若无条件清一次就把 A 的令牌抹掉了——要么同一个会话被补两条「已停止」标记,
764
+ * 要么 A 补到一半被判出局。带上判据之后 B 这一下是 no-op,它的 `acquire` 会老实报 busy。
765
+ */
766
+ async clearStale(conversationId) {
767
+ await col.updateOne({
768
+ _id: conversationId,
769
+ $or: staleOr(now())
770
+ }, { $set: {
771
+ holder: null,
772
+ leaseToken: null
773
+ } });
774
+ },
775
+ /**
776
+ * 打[待接手](../../../docs/terms.md)标记。幂等,行不存在也要能打——`upsert` 补一份
777
+ * 「没人持有」的空租约。
778
+ *
779
+ * **`seqWatermark` 垫成 `null`,不是 0**:这份文档从没被真正 `acquire` 过,水位还没从
780
+ * 账本问过。写死成 0 会让下一次 `acquire()` 误以为已经播种、跳过 `seedSeq()`,发号从 1
781
+ * 开始,跟账本里已有的 seq 撞车。`acquire()` 见到 `null` 会自己补种。
782
+ */
783
+ async markAwaitingTakeover(conversationId) {
784
+ await col.updateOne({ _id: conversationId }, {
785
+ $set: { awaitingTakeover: true },
786
+ $setOnInsert: {
787
+ holder: null,
788
+ leaseToken: null,
789
+ seqWatermark: null,
790
+ heartbeatAt: 0,
791
+ acquiredAt: now(),
792
+ reservedFor: null,
793
+ reservedUntil: null
794
+ }
795
+ }, { upsert: true });
796
+ },
797
+ /** 撤掉待接手标记。幂等:文档本来就不存在时是 no-op(不 upsert)。 */
798
+ async clearAwaitingTakeover(conversationId) {
799
+ await col.updateOne({ _id: conversationId }, { $set: { awaitingTakeover: false } });
800
+ },
801
+ /**
802
+ * [定时回捞](../../../docs/terms.md)候选:只看打了待接手标记的那些,排除掉「有活着的
803
+ * 持有者」与「有有效预留」——那两种已经有人会推,回捞抢了没意义。
804
+ *
805
+ * **候选不看待发队列是不是空的。** 队列不空不等于卡住——在等人答复、按设计排着后续
806
+ * 消息的对话,队列也不空,但永远推不动,会白白占满每次的 `limit`,挤掉真正卡住(打了
807
+ * 待接手标记)的那些。真正需要回捞推一把的队列,框架会在崩溃恢复、下线交接时主动打上
808
+ * 待接手标记,不需要这里再兜底扫队列。
809
+ */
810
+ async listSweepCandidates(sweepOpts) {
811
+ const at = now();
812
+ const candidateIds = (await col.find({ awaitingTakeover: true }, { projection: { _id: 1 } }).toArray()).map((doc) => doc._id);
813
+ if (candidateIds.length === 0) return [];
814
+ const docs = await col.find({ _id: { $in: candidateIds } }).toArray();
815
+ const byId = new Map(docs.map((doc) => [doc._id, doc]));
816
+ const result = [];
817
+ for (const id of candidateIds) {
818
+ if (result.length >= sweepOpts.limit) break;
819
+ const doc = byId.get(id) ?? null;
820
+ if (doc !== null && isLive(doc, at)) continue;
821
+ if (activeReservedFor(doc, at) !== void 0) continue;
822
+ result.push(id);
823
+ }
824
+ return result;
825
+ }
826
+ };
300
827
  }
828
+ //#endregion
829
+ //#region src/nodes.ts
830
+ /**
831
+ * 建一个节点登记表。
832
+ *
833
+ * ```ts
834
+ * const registry = mongoNodeRegistry(db);
835
+ * await registry.register({ node: process.env.RUNKO_NODE_URL, releaseSeq: Number(process.env.RUNKO_RELEASE_SEQ) });
836
+ * ```
837
+ */
838
+ function mongoNodeRegistry(db, opts = {}) {
839
+ const now = opts.now ?? Date.now;
840
+ const col = db.collection(NODES_COLLECTION);
841
+ const leases = db.collection(LEASES_COLLECTION);
842
+ const toRecord = (doc) => ({
843
+ node: doc._id,
844
+ releaseSeq: doc.releaseSeq,
845
+ state: doc.state === "leaving" ? "leaving" : "ready",
846
+ heartbeatAt: doc.heartbeatAt,
847
+ startedAt: doc.startedAt
848
+ });
849
+ return {
850
+ async register(self) {
851
+ const at = now();
852
+ await col.updateOne({ _id: self.node }, { $set: {
853
+ releaseSeq: self.releaseSeq,
854
+ state: "ready",
855
+ heartbeatAt: at,
856
+ startedAt: at
857
+ } }, { upsert: true });
858
+ },
859
+ async heartbeat(node) {
860
+ await col.updateOne({ _id: node }, { $set: { heartbeatAt: now() } });
861
+ },
862
+ async markLeaving(node) {
863
+ await col.updateOne({ _id: node }, { $set: { state: "leaving" } });
864
+ },
865
+ async remove(node) {
866
+ await col.deleteOne({ _id: node });
867
+ },
868
+ async candidates(self, candOpts) {
869
+ const at = now();
870
+ const docs = await col.find({
871
+ _id: { $ne: self.node },
872
+ state: "ready",
873
+ heartbeatAt: { $gte: at - candOpts.freshMs },
874
+ releaseSeq: { $gte: self.releaseSeq }
875
+ }).toArray();
876
+ const loadDocs = await leases.find({ holder: { $ne: null } }, { projection: { holder: 1 } }).toArray();
877
+ const loadByHolder = /* @__PURE__ */ new Map();
878
+ for (const doc of loadDocs) {
879
+ if (doc.holder === null) continue;
880
+ loadByHolder.set(doc.holder, (loadByHolder.get(doc.holder) ?? 0) + 1);
881
+ }
882
+ const candidates = docs.map((doc) => ({
883
+ ...toRecord(doc),
884
+ load: loadByHolder.get(doc._id) ?? 0
885
+ }));
886
+ candidates.sort((a, b) => {
887
+ const aTier = a.releaseSeq > self.releaseSeq ? 0 : 1;
888
+ const bTier = b.releaseSeq > self.releaseSeq ? 0 : 1;
889
+ if (aTier !== bTier) return aTier - bTier;
890
+ if (a.load !== b.load) return a.load - b.load;
891
+ return a.node < b.node ? -1 : a.node > b.node ? 1 : 0;
892
+ });
893
+ return candidates;
894
+ },
895
+ async list() {
896
+ return (await col.find({}).toArray()).map(toRecord);
897
+ }
898
+ };
899
+ }
900
+ //#endregion
901
+ //#region src/migrate.ts
301
902
  /**
302
903
  * 建一个具名索引,**同名不同选项时删了重建**。
303
904
  *
@@ -338,6 +939,18 @@ async function migrate(db) {
338
939
  }, {
339
940
  unique: true,
340
941
  name: "agent_queue_conversation_seq"
942
+ }),
943
+ ensureIndex(db, LEASES_COLLECTION, {
944
+ heartbeatAt: 1,
945
+ leaseToken: 1
946
+ }, { name: "agent_leases_heartbeat_token" }),
947
+ ensureIndex(db, LEASES_COLLECTION, { awaitingTakeover: 1 }, { name: "agent_leases_awaiting_takeover" }),
948
+ ensureIndex(db, TAILS_COLLECTION, {
949
+ conversationId: 1,
950
+ toolCallId: 1
951
+ }, {
952
+ unique: true,
953
+ name: "agent_tool_tails_conversation_call"
341
954
  })
342
955
  ]);
343
956
  }
@@ -350,20 +963,28 @@ const RUNKO_PERSIST_MONGO_VERSION = "0.0.0";
350
963
  * **吃 `Db` 而不是 `MongoClient`**:选哪个 database 是宿主的决定(多租户可能一租户
351
964
  * 一个 db),连接的生命周期也归宿主管——本包不 connect、不 close。
352
965
  *
353
- * 三个集合固定叫 `agent_ledger` / `agent_decisions` / `agent_queue`,你自己的集合
354
- * 照常在同一个 db 里,互不干扰。
966
+ * 四个集合固定叫 `agent_ledger` / `agent_decisions` / `agent_queue` / `agent_tool_tails`,
967
+ * 你自己的集合照常在同一个 db 里,互不干扰。
355
968
  */
356
969
  function mongoPersistence(db) {
357
970
  return {
358
971
  ledger: createLedgerStore(db),
359
972
  decisions: createDecisionStore(db),
360
- queue: createQueueStore(db)
973
+ queue: createQueueStore(db),
974
+ tails: createToolTailStore(db)
361
975
  };
362
976
  }
363
977
  //#endregion
364
978
  exports.DECISIONS_COLLECTION = DECISIONS_COLLECTION;
979
+ exports.DEFAULT_HEARTBEAT_MS = DEFAULT_HEARTBEAT_MS;
980
+ exports.DEFAULT_TAKEOVER_MS = DEFAULT_TAKEOVER_MS;
981
+ exports.LEASES_COLLECTION = LEASES_COLLECTION;
365
982
  exports.LEDGER_COLLECTION = LEDGER_COLLECTION;
983
+ exports.NODES_COLLECTION = NODES_COLLECTION;
366
984
  exports.QUEUE_COLLECTION = QUEUE_COLLECTION;
367
985
  exports.RUNKO_PERSIST_MONGO_VERSION = RUNKO_PERSIST_MONGO_VERSION;
986
+ exports.TAILS_COLLECTION = TAILS_COLLECTION;
368
987
  exports.migrate = migrate;
988
+ exports.mongoArbitration = mongoArbitration;
989
+ exports.mongoNodeRegistry = mongoNodeRegistry;
369
990
  exports.mongoPersistence = mongoPersistence;