niceeval 0.10.3-canary.6 → 0.11.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.
Files changed (122) hide show
  1. package/bin/niceeval.js +9 -4
  2. package/dist/agents/types.d.ts +5 -4
  3. package/dist/context/turn-errors.d.ts +27 -23
  4. package/dist/i18n/en.d.ts +15 -0
  5. package/dist/i18n/en.js +16 -1
  6. package/dist/i18n/zh-CN.d.ts +16 -1
  7. package/dist/i18n/zh-CN.js +16 -1
  8. package/dist/o11y/derive.js +28 -24
  9. package/dist/o11y/types.d.ts +8 -5
  10. package/dist/report/components/attempt-detail/UsageTable.js +4 -7
  11. package/dist/report/components/attempt-detail/compute.d.ts +2 -2
  12. package/dist/report/components/attempt-detail/compute.js +2 -6
  13. package/dist/report/components/attempt-detail/faces.js +7 -7
  14. package/dist/report/components/attempt-detail/index.js +0 -3
  15. package/dist/report/components/entity-lists/EvalList.js +0 -0
  16. package/dist/report/components/metric-views/compute.js +1 -1
  17. package/dist/report/model/types.d.ts +3 -4
  18. package/dist/results/locator.js +0 -0
  19. package/dist/results/select.d.ts +6 -0
  20. package/dist/results/select.js +8 -0
  21. package/dist/runner/feedback/sink.d.ts +26 -1
  22. package/dist/runner/fingerprint.d.ts +23 -0
  23. package/dist/runner/types.d.ts +105 -7
  24. package/dist/sandbox/errors.d.ts +29 -0
  25. package/dist/sandbox/resolve.d.ts +9 -0
  26. package/dist/shared/failure-class.d.ts +91 -0
  27. package/dist/types.d.ts +1 -0
  28. package/dist/util.d.ts +3 -2
  29. package/dist/util.js +31 -5
  30. package/docs-site/zh/explanation/runner.mdx +35 -0
  31. package/docs-site/zh/reference/cli.mdx +10 -1
  32. package/docs-site/zh/reference/events.mdx +4 -4
  33. package/docs-site/zh/troubleshooting/debugging.mdx +11 -0
  34. package/docs-site/zh/tutorials/viewing-results.mdx +35 -3
  35. package/package.json +28 -24
  36. package/src/agents/ai-sdk.test.ts +26 -0
  37. package/src/agents/ai-sdk.ts +7 -4
  38. package/src/agents/index.ts +5 -3
  39. package/src/agents/langgraph.test.ts +30 -0
  40. package/src/agents/langgraph.ts +5 -2
  41. package/src/agents/openai-compat.test.ts +35 -0
  42. package/src/agents/openai-compat.ts +16 -4
  43. package/src/agents/sdk-streams.test.ts +66 -0
  44. package/src/agents/sdk-streams.ts +3 -1
  45. package/src/agents/types.ts +5 -4
  46. package/src/cli.ts +81 -16
  47. package/src/context/context.test.ts +34 -0
  48. package/src/context/context.ts +14 -5
  49. package/src/context/send-retry.test.ts +86 -0
  50. package/src/context/send-retry.ts +37 -12
  51. package/src/context/session.ts +24 -0
  52. package/src/context/turn-errors.test.ts +124 -17
  53. package/src/context/turn-errors.ts +60 -50
  54. package/src/define.ts +5 -0
  55. package/src/i18n/en.ts +22 -1
  56. package/src/i18n/zh-CN.ts +22 -1
  57. package/src/index.ts +8 -0
  58. package/src/o11y/cost.ts +4 -2
  59. package/src/o11y/derive.test.ts +40 -0
  60. package/src/o11y/derive.ts +28 -22
  61. package/src/o11y/otlp/sandbox-receiver.test.ts +201 -0
  62. package/src/o11y/otlp/sandbox-receiver.ts +73 -27
  63. package/src/o11y/parsers/bub.test.ts +30 -0
  64. package/src/o11y/parsers/bub.ts +5 -2
  65. package/src/o11y/parsers/codex.test.ts +19 -0
  66. package/src/o11y/parsers/codex.ts +5 -2
  67. package/src/o11y/types.ts +8 -5
  68. package/src/report/components/attempt-detail/UsageTable.tsx +4 -6
  69. package/src/report/components/attempt-detail/attempt-components.test.tsx +5 -8
  70. package/src/report/components/attempt-detail/compute.ts +2 -7
  71. package/src/report/components/attempt-detail/faces.ts +7 -7
  72. package/src/report/components/attempt-detail/index.tsx +0 -3
  73. package/src/report/components/entity-lists/EvalList.tsx +0 -0
  74. package/src/report/components/metric-views/compute.ts +1 -1
  75. package/src/report/model/types.ts +3 -4
  76. package/src/results/format.ts +9 -2
  77. package/src/results/index.ts +2 -0
  78. package/src/results/locator.ts +0 -0
  79. package/src/results/open.ts +132 -21
  80. package/src/results/select.ts +12 -0
  81. package/src/results/skipped-notice.ts +0 -0
  82. package/src/runner/attempt.test.ts +116 -0
  83. package/src/runner/attempt.ts +89 -8
  84. package/src/runner/discover.ts +21 -3
  85. package/src/runner/feedback/coordinator.ts +24 -2
  86. package/src/runner/feedback/eval-conclusions.ts +6 -3
  87. package/src/runner/feedback/human.test.ts +300 -6
  88. package/src/runner/feedback/human.ts +132 -30
  89. package/src/runner/feedback/json.test.ts +127 -2
  90. package/src/runner/feedback/json.ts +51 -3
  91. package/src/runner/feedback/reducer.test.ts +328 -29
  92. package/src/runner/feedback/reducer.ts +84 -9
  93. package/src/runner/feedback/sink.ts +39 -1
  94. package/src/runner/fingerprint.ts +49 -19
  95. package/src/runner/gate-lease.test.ts +510 -0
  96. package/src/runner/gate-lease.ts +350 -0
  97. package/src/runner/lock.test.ts +454 -0
  98. package/src/runner/lock.ts +288 -0
  99. package/src/runner/report.test.ts +1 -0
  100. package/src/runner/run.test.ts +2044 -9
  101. package/src/runner/run.ts +825 -61
  102. package/src/runner/teardown-registry.ts +20 -78
  103. package/src/runner/types.ts +103 -7
  104. package/src/sandbox/errors.test.ts +72 -0
  105. package/src/sandbox/errors.ts +83 -0
  106. package/src/sandbox/keep-registry.ts +22 -46
  107. package/src/sandbox/resolve.test.ts +100 -0
  108. package/src/sandbox/resolve.ts +84 -27
  109. package/src/shared/entry-file-store.test.ts +149 -0
  110. package/src/shared/entry-file-store.ts +117 -0
  111. package/src/shared/failure-class.test.ts +137 -0
  112. package/src/shared/failure-class.ts +175 -0
  113. package/src/show/index.ts +5 -6
  114. package/src/show/render.test.ts +116 -15
  115. package/src/show/render.ts +124 -35
  116. package/src/types.ts +9 -0
  117. package/src/util.ts +31 -4
  118. package/src/view/app/App.tsx +5 -1
  119. package/src/view/client-dist/app.js +1 -1
  120. package/src/view/data.ts +5 -9
  121. package/src/view/shared/types.ts +7 -1
  122. package/src/view/view-report.test.ts +54 -0
@@ -0,0 +1,350 @@
1
+ // 实验闸租约:把实验级 `maxConcurrency` 的 N 个名额做成跨 Invocation 共用的逐槽租约。
2
+ // 与 ./lock.ts 的用例锁同一套文件纪律(O_EXCL 独占创建、心跳续租、过期判据、rename 接管、
3
+ // 释放即删除),建在 ../shared/entry-file-store.ts 的原语之上,不复制第三份纪律。
4
+ // 契约见 docs/feature/experiments/architecture.md「并发 Invocation:用例锁」末条与
5
+ // docs/runner.md#调度有界并发。
6
+
7
+ import { mkdir, open, rm } from "node:fs/promises";
8
+ import { join } from "node:path";
9
+ import {
10
+ claimEntryFile,
11
+ fsyncDir,
12
+ readAllEntryFiles,
13
+ readEntryFile,
14
+ slugHashEntryId,
15
+ writeEntryFile,
16
+ } from "../shared/entry-file-store.ts";
17
+ import { CASE_LOCK_HEARTBEAT_INTERVAL_MS, CASE_LOCK_STALE_MS, locksDirOf } from "./lock.ts";
18
+
19
+ /** 逐槽租约文件的 JSON 形状。身份的权威在内容,文件名只须无碰撞、不承载解析。 */
20
+ export interface GateLeaseRecord {
21
+ experimentId: string;
22
+ /** 槽位序号,取值 0..N-1。 */
23
+ slot: number;
24
+ /** 本持有者 resolved 的名额上限 N。用于 min-N:生效名额取在场声明的最小值。 */
25
+ declaredN: number;
26
+ pid: number;
27
+ host: string;
28
+ startedAt: string; // ISO
29
+ heartbeatAt: string; // ISO
30
+ }
31
+
32
+ /** 持有者续租心跳的周期。与用例锁同参数。 */
33
+ export const GATE_LEASE_HEARTBEAT_INTERVAL_MS = CASE_LOCK_HEARTBEAT_INTERVAL_MS;
34
+ /** `heartbeatAt` 落后当前时间超过这个阈值(三个心跳周期)即视为持有者已死。 */
35
+ export const GATE_LEASE_STALE_MS = CASE_LOCK_STALE_MS;
36
+
37
+ export interface GateLeaseClaim {
38
+ /** 实际取到的槽位序号。 */
39
+ slot: number;
40
+ /** 停止心跳定时器并删除租约文件。幂等——重复调用是 no-op。 */
41
+ release(): Promise<void>;
42
+ }
43
+
44
+ export interface AcquireGateSlotResult {
45
+ claim: GateLeaseClaim;
46
+ /** true 当且仅当本次取位接管了一条过期租约,而不是全新创建。 */
47
+ takenOver: boolean;
48
+ /** 被接管的原持有者记录,仅在 `takenOver` 时有值——供 `lock-taken-over` 诊断报出。 */
49
+ takenOverFrom?: GateLeaseRecord;
50
+ }
51
+
52
+ export function gateLeasesDirOf(niceevalRoot: string): string {
53
+ return locksDirOf(niceevalRoot);
54
+ }
55
+
56
+ function gateLeaseEntryId(experimentId: string, slot: number): string {
57
+ return slugHashEntryId(`gate-${experimentId}-${slot}`, ["gate-lease", experimentId, String(slot)]);
58
+ }
59
+
60
+ /** 租约与用例锁同住 `.niceeval/locks/`,分辨走内容而不是文件名:只有租约带 `slot` + `declaredN`。 */
61
+ function isGateLeaseRecordOf(entry: unknown, experimentId: string): entry is GateLeaseRecord {
62
+ if (typeof entry !== "object" || entry === null) return false;
63
+ const r = entry as Partial<GateLeaseRecord>;
64
+ return (
65
+ r.experimentId === experimentId &&
66
+ typeof r.slot === "number" &&
67
+ typeof r.declaredN === "number" &&
68
+ typeof r.heartbeatAt === "string"
69
+ );
70
+ }
71
+
72
+ /** 读取该实验当前在场的全部租约记录,无副作用。`--dry` 与 min-N 扫描用它。 */
73
+ export async function readGateLeases(niceevalRoot: string, experimentId: string): Promise<GateLeaseRecord[]> {
74
+ const entries = await readAllEntryFiles<unknown>(gateLeasesDirOf(niceevalRoot));
75
+ return entries
76
+ .map(({ entry }) => entry)
77
+ .filter((entry): entry is GateLeaseRecord => isGateLeaseRecordOf(entry, experimentId))
78
+ .sort((a, b) => a.slot - b.slot);
79
+ }
80
+
81
+ /** 过期判据:只看心跳时间戳,不看 pid。落后严格大于阈值才算过期;无法解析一律视为过期。 */
82
+ export function isGateLeaseStale(record: GateLeaseRecord, nowMs: number): boolean {
83
+ const heartbeatMs = Date.parse(record.heartbeatAt);
84
+ if (Number.isNaN(heartbeatMs)) return true;
85
+ return nowMs - heartbeatMs > GATE_LEASE_STALE_MS;
86
+ }
87
+
88
+ /** 本进程声明的 N:非法值(非有限、小于 1)一律收敛到 1,避免生效名额算成 0 造成永久满位。 */
89
+ function normalizeN(n: number): number {
90
+ const floored = Math.floor(n);
91
+ return Number.isFinite(floored) && floored >= 1 ? floored : 1;
92
+ }
93
+
94
+ /**
95
+ * min-N:生效名额 = min(自己 resolved 的 N, 在场租约声明的每个 N)。配置漂移下正确性从紧——
96
+ * 只要有一条在场租约声明了更小的 N,本进程就按那个更小的名额取位。过期租约的持有者已死,
97
+ * 它的声明不参与(否则一条永不清理的残留租约会把名额永久钉死在它的 N 上)。
98
+ */
99
+ function effectiveSlotCount(maxConcurrency: number, leases: readonly GateLeaseRecord[], nowMs: number): number {
100
+ let n = normalizeN(maxConcurrency);
101
+ for (const lease of leases) {
102
+ if (isGateLeaseStale(lease, nowMs)) continue;
103
+ const declared = normalizeN(lease.declaredN);
104
+ if (declared < n) n = declared;
105
+ }
106
+ return n;
107
+ }
108
+
109
+ /**
110
+ * O_EXCL 独占创建租约文件;已存在则返回 false,不覆盖、不抛错。这是「一个槽只能被一个进程
111
+ * 占住」的互斥点本身,与 lock.ts 的同名私有函数同构——共享层 `writeEntryFile` 是「写入或
112
+ * 覆盖」语义(rename 无条件替换目标),在这里会让两个进程同时认为自己占到了同一个槽。
113
+ */
114
+ async function createLeaseFileExclusive(dir: string, id: string, record: GateLeaseRecord): Promise<boolean> {
115
+ await mkdir(dir, { recursive: true });
116
+ const path = join(dir, `${id}.json`);
117
+ try {
118
+ const handle = await open(path, "wx");
119
+ try {
120
+ await handle.writeFile(JSON.stringify(record, null, 2), "utf-8");
121
+ await handle.sync();
122
+ } finally {
123
+ await handle.close();
124
+ }
125
+ } catch (e) {
126
+ if ((e as NodeJS.ErrnoException).code === "EEXIST") return false;
127
+ throw e;
128
+ }
129
+ await fsyncDir(dir);
130
+ return true;
131
+ }
132
+
133
+ /** 同一持有者判定:身份(pid/host)加上取位时刻——接管重建后 `startedAt` 必然不同。 */
134
+ function isSameHolder(record: GateLeaseRecord, mine: GateLeaseRecord): boolean {
135
+ return record.pid === mine.pid && record.host === mine.host && record.startedAt === mine.startedAt;
136
+ }
137
+
138
+ /**
139
+ * 一次非阻塞取位尝试:先按在场租约算生效名额(min-N——取自己的 `maxConcurrency` 与在场
140
+ * 租约声明的 `declaredN` 的最小值),再对 `0..effectiveN-1` 中任一空槽 O_EXCL 独占创建;
141
+ * 全满时若有过期槽则经 rename 接管,都不成功即 `{kind:"full"}`。
142
+ */
143
+ export async function tryAcquireGateSlotOnce(
144
+ niceevalRoot: string,
145
+ experimentId: string,
146
+ maxConcurrency: number,
147
+ identity: { pid: number; host: string },
148
+ nowMs: number,
149
+ ): Promise<
150
+ | { kind: "acquired"; slot: number; takenOver: boolean; takenOverFrom?: GateLeaseRecord; record: GateLeaseRecord }
151
+ | { kind: "full"; holders: GateLeaseRecord[] }
152
+ > {
153
+ const dir = gateLeasesDirOf(niceevalRoot);
154
+ const effectiveN = effectiveSlotCount(maxConcurrency, await readGateLeases(niceevalRoot, experimentId), nowMs);
155
+ const stamp = new Date(nowMs).toISOString();
156
+ const recordFor = (slot: number): GateLeaseRecord => ({
157
+ experimentId,
158
+ slot,
159
+ // 声明的是自己 resolved 的 N,不是本次算出的生效名额:否则被别人压低一次之后,这个更小的
160
+ // 值会经租约文件传染开去,对方退场也回不去。
161
+ declaredN: normalizeN(maxConcurrency),
162
+ pid: identity.pid,
163
+ host: identity.host,
164
+ startedAt: stamp,
165
+ heartbeatAt: stamp,
166
+ });
167
+
168
+ // 第一趟:逐槽独占创建。不预读占用情况——O_EXCL 本身就是判据,预读只会多一次竞态窗口。
169
+ for (let slot = 0; slot < effectiveN; slot += 1) {
170
+ const record = recordFor(slot);
171
+ if (await createLeaseFileExclusive(dir, gateLeaseEntryId(experimentId, slot), record)) {
172
+ return { kind: "acquired", slot, takenOver: false, record };
173
+ }
174
+ }
175
+
176
+ // 第二趟:全满,找过期槽接管。claim(rename-墓碑)是互斥点,输者不报错、换下一个槽继续试;
177
+ // 一趟走完仍无所获就返回 full,由上层轮询重新评估——不递归,避免竞争激烈时栈深不可控。
178
+ for (let slot = 0; slot < effectiveN; slot += 1) {
179
+ const id = gateLeaseEntryId(experimentId, slot);
180
+ const current = await readEntryFile<GateLeaseRecord>(dir, id);
181
+ if (current === undefined || !isGateLeaseStale(current, nowMs)) continue;
182
+ if (!(await claimEntryFile(dir, id))) continue;
183
+ const record = recordFor(slot);
184
+ if (!(await createLeaseFileExclusive(dir, id, record))) continue;
185
+ return { kind: "acquired", slot, takenOver: true, takenOverFrom: current, record };
186
+ }
187
+
188
+ const holders = (await readGateLeases(niceevalRoot, experimentId)).filter(
189
+ (lease) => !isGateLeaseStale(lease, nowMs),
190
+ );
191
+ return { kind: "full", holders };
192
+ }
193
+
194
+ /**
195
+ * 续租:只重写 `heartbeatAt`,其余字段原样保留;槽已经不是自己的(被接管)就不写,不踩别人的
196
+ * 租约。`isReleased` 是 `acquireGateSlot` 闭包里 `released` 标志的读取器——`release()` 只
197
+ * `clearInterval`,拦不住已经进入回调、卡在 `readEntryFile` 这次 await 上的心跳:它读完时
198
+ * 租约文件可能已被 `rm`,若不再确认就直接写回,会把刚删掉的文件重新创建出来(与 lock.ts 同一条
199
+ * 竞态,见 memory/lock-heartbeat-resurrects-released-lock.md)。写回前必须再查一次;入口也查
200
+ * 一次省一次读,但不是竞态的关键检查点。
201
+ */
202
+ async function renewHeartbeat(
203
+ dir: string,
204
+ id: string,
205
+ mine: GateLeaseRecord,
206
+ nowMs: number,
207
+ isReleased: () => boolean,
208
+ ): Promise<void> {
209
+ if (isReleased()) return;
210
+ const current = await readEntryFile<GateLeaseRecord>(dir, id);
211
+ if (current === undefined) return; // 租约已经不在了(已释放或被接管),没有心跳可续
212
+ if (!isSameHolder(current, mine)) return;
213
+ if (isReleased()) return; // 释放发生在上面这次 await 期间——写回之前的最后一道闸
214
+ await writeEntryFile(dir, id, { ...current, heartbeatAt: new Date(nowMs).toISOString() });
215
+ }
216
+
217
+ function makeAbortError(signal: AbortSignal | undefined): Error {
218
+ const reason = signal?.reason;
219
+ if (reason instanceof Error) return reason;
220
+ const err = new Error("aborted while waiting for experiment gate slot");
221
+ err.name = "AbortError";
222
+ return err;
223
+ }
224
+
225
+ function throwIfAborted(signal: AbortSignal | undefined): void {
226
+ if (signal?.aborted) throw makeAbortError(signal);
227
+ }
228
+
229
+ /** 可被 AbortSignal 中断的延时;abort 时立刻 reject,不留下悬挂的定时器。 */
230
+ function delayOrAbort(ms: number, signal: AbortSignal | undefined): Promise<void> {
231
+ if (signal?.aborted) return Promise.reject(makeAbortError(signal));
232
+ return new Promise((resolve, reject) => {
233
+ const timer = setTimeout(() => {
234
+ cleanup();
235
+ resolve();
236
+ }, ms);
237
+ const onAbort = (): void => {
238
+ cleanup();
239
+ reject(makeAbortError(signal));
240
+ };
241
+ const cleanup = (): void => {
242
+ clearTimeout(timer);
243
+ signal?.removeEventListener("abort", onAbort);
244
+ };
245
+ signal?.addEventListener("abort", onAbort, { once: true });
246
+ });
247
+ }
248
+
249
+ // 当前进程持有中的租约,供 drainHeldGateLeases 强清兜底排空——与 lock.ts 的 held 表同一个模式。
250
+ const held = new Map<string, () => Promise<void>>();
251
+
252
+ function heldKey(niceevalRoot: string, id: string): string {
253
+ return `${niceevalRoot} ${id}`;
254
+ }
255
+
256
+ /**
257
+ * 高层入口:立刻取位,或者每 `pollIntervalMs`(默认等于心跳周期)重试一次直到取到。没有
258
+ * 超时——在场租约心跳新鲜就一直等。取位成功后启动心跳续租定时器,并把释放闭包登记进
259
+ * 模块内的「本进程持有中」表(供 `drainHeldGateLeases` 强清兜底)。必须响应 `opts.signal`:
260
+ * 等待期间被中断要立刻停止轮询、以 AbortError 形状的错误 reject,不留下悬挂的定时器。
261
+ */
262
+ export async function acquireGateSlot(
263
+ niceevalRoot: string,
264
+ experimentId: string,
265
+ maxConcurrency: number,
266
+ identity: { pid: number; host: string },
267
+ opts: {
268
+ signal?: AbortSignal;
269
+ pollIntervalMs?: number;
270
+ heartbeatIntervalMs?: number;
271
+ /** 只在第一次尝试就撞满时触发一次,取位成功不触发。 */
272
+ onWaitStart?: (holders: GateLeaseRecord[]) => void;
273
+ } = {},
274
+ ): Promise<AcquireGateSlotResult> {
275
+ const heartbeatIntervalMs = opts.heartbeatIntervalMs ?? GATE_LEASE_HEARTBEAT_INTERVAL_MS;
276
+ const pollIntervalMs = opts.pollIntervalMs ?? heartbeatIntervalMs;
277
+ const { signal } = opts;
278
+
279
+ throwIfAborted(signal);
280
+
281
+ let waitStarted = false;
282
+ let slot!: number;
283
+ let takenOver = false;
284
+ let takenOverFrom: GateLeaseRecord | undefined;
285
+ // 取位那一刻落盘的记录就是自己的身份凭据:续租与释放都先核对,槽被接管之后不动别人的租约。
286
+ let mine!: GateLeaseRecord;
287
+ for (;;) {
288
+ const result = await tryAcquireGateSlotOnce(niceevalRoot, experimentId, maxConcurrency, identity, Date.now());
289
+ if (result.kind === "acquired") {
290
+ slot = result.slot;
291
+ takenOver = result.takenOver;
292
+ takenOverFrom = result.takenOverFrom;
293
+ mine = result.record;
294
+ break;
295
+ }
296
+ if (!waitStarted) {
297
+ waitStarted = true;
298
+ opts.onWaitStart?.(result.holders);
299
+ }
300
+ await delayOrAbort(pollIntervalMs, signal);
301
+ }
302
+
303
+ const dir = gateLeasesDirOf(niceevalRoot);
304
+ const id = gateLeaseEntryId(experimentId, slot);
305
+ const key = heldKey(niceevalRoot, id);
306
+
307
+ // `inFlight` 追踪当前正在飞的心跳续租调用。仅在写回前查一次 `released` 不够:一旦某次心跳
308
+ // 通过了检查、开始调用 `writeEntryFile`,该调用内部(mkdir/写临时文件/rename)本身还有多个
309
+ // await 点——release() 可能在这些 await 之间跑完 `rm`,随后心跳的 rename 落地,把刚删掉的
310
+ // 文件重新创建出来。真正堵住这条缝的办法不是"检查更早",而是让 release() 在删除之前等所有
311
+ // 已发起的心跳调用结束,保证不会有写回落在 rm 之后(与 lock.ts 同构)。
312
+ let released = false;
313
+ const inFlight = new Set<Promise<void>>();
314
+ const timer = setInterval(() => {
315
+ if (released) return;
316
+ const task = renewHeartbeat(dir, id, mine, Date.now(), () => released).catch(() => {
317
+ // 心跳续租失败(如磁盘瞬时错误)不应该让定时器本身崩溃;下一个周期再试。
318
+ });
319
+ inFlight.add(task);
320
+ void task.finally(() => inFlight.delete(task));
321
+ }, heartbeatIntervalMs);
322
+ timer.unref?.();
323
+
324
+ const release = async (): Promise<void> => {
325
+ if (released) return;
326
+ released = true;
327
+ clearInterval(timer);
328
+ held.delete(key);
329
+ await Promise.all(inFlight); // 等在飞心跳全部落地(写或不写),再删——不然写回可能晚于 rm
330
+ const current = await readEntryFile<GateLeaseRecord>(dir, id);
331
+ if (current !== undefined && !isSameHolder(current, mine)) return; // 槽已被接管,不删别人的租约
332
+ await rm(join(dir, `${id}.json`), { force: true });
333
+ await fsyncDir(dir);
334
+ };
335
+ held.set(key, release);
336
+
337
+ return { claim: { slot, release }, takenOver, takenOverFrom };
338
+ }
339
+
340
+ /** 强清兜底:释放当前进程持有的每一条租约(尽力而为、幂等)。返回本次排空的条数。 */
341
+ export async function drainHeldGateLeases(): Promise<number> {
342
+ const releases = [...held.values()];
343
+ await Promise.allSettled(releases.map((release) => release()));
344
+ return releases.length;
345
+ }
346
+
347
+ /** 测试探针,镜像 `pendingHeldCaseLockCount`。 */
348
+ export function pendingHeldGateLeaseCount(): number {
349
+ return held.size;
350
+ }