@mnemonik/shared 7.153.0 → 7.154.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.
@@ -1,3 +1,5 @@
1
+ import diagnosticsChannel from 'node:diagnostics_channel';
2
+
1
3
  /**
2
4
  * Hook dispatcher HTTP timeout budgets + AbortSignal helper.
3
5
  *
@@ -108,51 +110,63 @@ export function withHookTimeout(ms: number): { signal: AbortSignal; cleanup: ()
108
110
  }
109
111
 
110
112
  /**
111
- * One second attempt on the main hook call (POST /api/v1/hooks/injections).
113
+ * The main hook call (POST /api/v1/hooks/injections) is hedged: when the first
114
+ * request has not answered by HOOK_HEDGE_SHARE of the budget, a second request
115
+ * starts beside it on a fresh connection, and whichever answers first is used.
116
+ *
117
+ * Why a second request at all: on 24 October 2026 about a third of one
118
+ * machine's hook calls were lost for 50 minutes to a stall between Cloudflare
119
+ * and one load-balancer node. The server had accepted those requests and was
120
+ * waiting for a body that never finished arriving. A second request opens a new
121
+ * connection and normally lands on a healthy path.
112
122
  *
113
- * Why it exists: on 24 October 2026 about a third of one machine's hook calls
114
- * were lost for 50 minutes to a stall between Cloudflare and one load-balancer
115
- * node. The server had accepted those requests and was waiting for a body that
116
- * never finished arriving. A second request opens a new connection and
117
- * normally lands on a healthy path. The call is idempotent by design (the
118
- * injection bus dedups on the server), so repeating it is safe.
123
+ * Why it is a hedge and not a retry: v7.149.0 abandoned the first request at
124
+ * 65% of the budget and then retried. Every hook process opens its own
125
+ * connection (DNS, TCP and TLS from the user's machine to Cloudflare), and the
126
+ * credential is read before the request leaves, so on 25 October that first
127
+ * request had often been on the wire for only a few hundred milliseconds when
128
+ * it was abandoned. Answers that were merely slow were thrown away, half the
129
+ * calls on the owner's machine needed two requests, about 8% lost both, and the
130
+ * load balancer counted each abandoned request as a client close (ELB 460). A
131
+ * hedge never abandons an answer that is on its way: the first request keeps
132
+ * running to the end of the budget.
119
133
  *
120
- * Shape: the whole call, both attempts included, still ends inside `budgetMs`,
121
- * so the budget every hook event is checked against (tests/HookEventBudgets
122
- * .test.ts) and the host's kill limit do not move. The first attempt gets
123
- * HOOK_FIRST_ATTEMPT_SHARE of the budget; the second gets whatever is left.
124
- * A first attempt that fails fast leaves the second nearly the whole budget.
134
+ * Both requests share one deadline, `budgetMs` from the call, so the budget
135
+ * every hook event is checked against (tests/HookEventBudgets.test.ts) and the
136
+ * host's kill limit do not move. A first request that fails fast (a connection
137
+ * error or a gateway status) starts the second at once instead of at the hedge
138
+ * point.
125
139
  *
126
- * What is retried: a timeout, a connection error, and the gateway statuses
127
- * that mean the request never reached the application (408, 502, 503, 504 and
128
- * Cloudflare's 52x). Nothing else is: a 4xx or a 500 repeats exactly, and a
129
- * response that arrives is final.
140
+ * What counts as an answer: any response other than a gateway status (408, 502,
141
+ * 503, 504, Cloudflare's 52x) and other than the server's "the first request is
142
+ * still working" reply (HOOK_REPLAY_HEADER: pending). A 4xx or a 500 is an
143
+ * answer (null to the caller): repeating it would repeat the failure.
130
144
  *
131
- * Nothing is queued or stored on the client. A call that fails twice is dropped,
132
- * as before. The server keeps an answer for 15 s so the second attempt can have
133
- * it when the first attempt gave up on an answer that was already decided.
145
+ * The server de-duplicates the two requests by HOOK_REQUEST_ID_HEADER
146
+ * (src/server/hookReplay.ts): the second request is handed the first one's
147
+ * answer, or waits for it, and nothing is counted twice. Nothing is queued or
148
+ * stored on the client; a call with no answer by the deadline is dropped.
134
149
  */
135
- export const HOOK_FIRST_ATTEMPT_SHARE = 0.65;
150
+ export const HOOK_HEDGE_SHARE = 0.65;
136
151
 
137
- /** Below this much budget left, a second attempt cannot complete a round trip. */
152
+ /** Below this much budget left, a second request cannot complete a round trip. */
138
153
  export const HOOK_SECOND_ATTEMPT_MIN_MS = 300;
139
154
 
140
- /** Sent on every attempt so the server can count how many calls needed a second one. */
155
+ /** Sent on every request: 1 for the first, 2 for the hedge. */
141
156
  export const HOOK_ATTEMPT_HEADER = 'x-mnemonik-hook-attempt';
142
157
 
143
158
  /**
144
- * Sent on both attempts with the same value. The server keeps the first
145
- * attempt's answer for a few seconds under this id and hands it to the second
146
- * attempt, so an answer decided after the first attempt gave up is delivered
147
- * rather than lost, and nothing is counted twice (src/server/hookReplay.ts).
159
+ * Sent on both requests with the same value. The server keeps the first
160
+ * request's answer for a few seconds under this id and hands it to the second,
161
+ * so an answer decided by one request is delivered through either, and nothing
162
+ * is counted twice (src/server/hookReplay.ts).
148
163
  */
149
164
  export const HOOK_REQUEST_ID_HEADER = 'x-mnemonik-hook-request-id';
150
165
 
151
166
  /**
152
- * Sent on every attempt: how many milliseconds this attempt may still take.
153
- * The server never waits for a first attempt's answer longer than this less a
154
- * margin for the return trip, so it does not hold a request for a client that
155
- * has already given up.
167
+ * Sent on every request: how many milliseconds the caller will still wait. The
168
+ * server fits its own work inside this less the return trip, so it never
169
+ * decides an answer the client will not be there to receive.
156
170
  */
157
171
  export const HOOK_BUDGET_HEADER = 'x-mnemonik-hook-budget-ms';
158
172
 
@@ -171,11 +185,75 @@ export function withHookVersion<T extends { headers?: unknown }>(
171
185
  runtime: string,
172
186
  version: string
173
187
  ): T {
174
- const headers = new Headers(init.headers as ConstructorParameters<typeof Headers>[0]);
188
+ const raw = init.headers;
189
+ if (raw && typeof raw === 'object' && !(raw instanceof Headers) && !Array.isArray(raw)) {
190
+ // A plain record is copied with its property descriptors, not its values:
191
+ // the hedged hook client's budget header is a getter read when the
192
+ // request is sent, after the credential is read (hedgedHookRequest).
193
+ const headers = Object.defineProperties({}, Object.getOwnPropertyDescriptors(raw)) as Record<
194
+ string,
195
+ string
196
+ >;
197
+ for (const name of Object.keys(headers)) {
198
+ if (name.toLowerCase() === HOOK_VERSION_HEADER) delete headers[name];
199
+ }
200
+ headers[HOOK_VERSION_HEADER] = `${runtime}/${version}`;
201
+ return { ...init, headers };
202
+ }
203
+ const headers = new Headers(raw as ConstructorParameters<typeof Headers>[0]);
175
204
  headers.set(HOOK_VERSION_HEADER, `${runtime}/${version}`);
176
205
  return { ...init, headers: Object.fromEntries(headers.entries()) };
177
206
  }
178
207
 
208
+ /**
209
+ * Sent on every request by a hedging client (hedgedHookRequest). Its budget
210
+ * header is what the whole call still has; clients v7.149 to v7.151 sent the
211
+ * first attempt's own 1.3 s cut-off instead, so a server that sees no marker
212
+ * assumes the whole-call 2 s budget rather than trusting that header.
213
+ */
214
+ export const HOOK_HEDGED_HEADER = 'x-mnemonik-hook-hedged';
215
+
216
+ /**
217
+ * Sent on the second request only: what had become of the first when the second
218
+ * left. `pending; sent=812` (still waiting, its body finished sending 812 ms
219
+ * earlier), `pending; sent=none` (it had not finished sending, so the client
220
+ * itself was slow), `gateway; sent=<ms>` (the edge answered 502/503/504/52x) or
221
+ * `transport; sent=<ms>` (the connection failed). The server uses it to count
222
+ * requests the network lost on the way in, which is what the edge alarm reads
223
+ * (src/server/hookReplay.ts, hookEdgeMetrics.ts).
224
+ */
225
+ export const HOOK_FIRST_ATTEMPT_HEADER = 'x-mnemonik-hook-first-attempt';
226
+
227
+ /**
228
+ * Set by the server on a reply that is not an answer: the request found the
229
+ * other request of the same call still working on it and could not wait any
230
+ * longer. The client keeps waiting for the other request.
231
+ */
232
+ export const HOOK_REPLAY_HEADER = 'x-mnemonik-hook-replay';
233
+ export const HOOK_REPLAY_PENDING = 'pending';
234
+
235
+ export type HookFirstAttemptState = 'pending' | 'gateway' | 'transport';
236
+
237
+ export interface HookFirstAttempt {
238
+ state: HookFirstAttemptState;
239
+ /** Milliseconds since the first request finished sending its body; null when it had not. */
240
+ sentAgoMs: number | null;
241
+ }
242
+
243
+ export function formatHookFirstAttempt(first: HookFirstAttempt): string {
244
+ return `${first.state}; sent=${first.sentAgoMs === null ? 'none' : Math.max(0, Math.round(first.sentAgoMs))}`;
245
+ }
246
+
247
+ export function parseHookFirstAttempt(value: unknown): HookFirstAttempt | null {
248
+ if (typeof value !== 'string') return null;
249
+ const match = /^(pending|gateway|transport); sent=(none|\d{1,6})$/.exec(value.trim());
250
+ if (!match) return null;
251
+ return {
252
+ state: match[1] as HookFirstAttemptState,
253
+ sentAgoMs: match[2] === 'none' ? null : Number(match[2]),
254
+ };
255
+ }
256
+
179
257
  /** Statuses that mean the request did not reach, or was not served by, the application. */
180
258
  export function isRetryableHookStatus(status: number): boolean {
181
259
  return (
@@ -200,44 +278,157 @@ export function isTransientHookFetchError(error: unknown): boolean {
200
278
  );
201
279
  }
202
280
 
203
- export type HookAttemptResult<T> = { done: true; value: T } | { done: false };
281
+ /**
282
+ * When the first request finished sending its body, read from Node's fetch
283
+ * (undici) diagnostics channels. Matched by the request id header, so other
284
+ * requests the process makes at the same time are ignored. Reports null when
285
+ * the channels are unavailable or the body never finished sending; nothing
286
+ * depends on it except the HOOK_FIRST_ATTEMPT_HEADER the server counts.
287
+ */
288
+ function watchFirstRequestSent(
289
+ requestId: string,
290
+ clock: () => number
291
+ ): { sentAt(): number | null; stop(): void } {
292
+ let sentAt: number | null = null;
293
+ let request: object | null = null;
294
+ const onCreate = (message: unknown): void => {
295
+ const candidate = (message as { request?: { headers?: unknown } } | null)?.request;
296
+ if (request || !candidate) return;
297
+ if (String(candidate.headers ?? '').includes(requestId)) request = candidate;
298
+ };
299
+ const onBodySent = (message: unknown): void => {
300
+ if (sentAt === null && request && (message as { request?: object })?.request === request) {
301
+ sentAt = clock();
302
+ }
303
+ };
304
+ try {
305
+ diagnosticsChannel.subscribe('undici:request:create', onCreate);
306
+ diagnosticsChannel.subscribe('undici:request:bodySent', onBodySent);
307
+ } catch {
308
+ return { sentAt: () => null, stop: () => undefined };
309
+ }
310
+ return {
311
+ sentAt: () => sentAt,
312
+ stop: () => {
313
+ try {
314
+ diagnosticsChannel.unsubscribe('undici:request:create', onCreate);
315
+ diagnosticsChannel.unsubscribe('undici:request:bodySent', onBodySent);
316
+ } catch {
317
+ // Nothing to undo.
318
+ }
319
+ },
320
+ };
321
+ }
322
+
323
+ type HookAttemptOutcome<T> =
324
+ { final: true; value: T | null } | { final: false; kind: 'gateway' | 'transport' | 'pending' };
204
325
 
205
326
  /**
206
- * Run `attempt` and, when it ends in a transient failure, run it once more
207
- * inside the rest of `budgetMs`. Returns the value of the attempt that
208
- * finished, or null when neither did. `attempt` returns `{ done: false }` for
209
- * a retryable response and throws for a transport failure; anything else it
210
- * returns is final.
327
+ * Send one hook call as up to two hedged requests inside `budgetMs` (see
328
+ * HOOK_HEDGE_SHARE). `send` performs one request with the headers and signal it
329
+ * is given; `parse` reads an answer. Returns the first answer, null when no
330
+ * request answered in time or the answer was an error status, and rejects only
331
+ * when `send` or `parse` fails in a way that is not a transport failure.
211
332
  */
212
- export async function withSecondAttempt<T>(
333
+ export function hedgedHookRequest<T>(
213
334
  budgetMs: number,
214
- attempt: (
215
- signal: AbortSignal,
216
- attemptNumber: 1 | 2,
217
- requestId: string,
218
- budgetMs: number
219
- ) => Promise<HookAttemptResult<T>>,
335
+ send: (headers: Record<string, string>, signal: AbortSignal) => Promise<Response>,
336
+ parse: (response: Response) => Promise<T>,
220
337
  clock: () => number = Date.now
221
338
  ): Promise<T | null> {
222
339
  const startedAt = clock();
340
+ const deadline = startedAt + budgetMs;
223
341
  const requestId = globalThis.crypto.randomUUID();
224
- const run = async (attemptNumber: 1 | 2, timeoutMs: number): Promise<HookAttemptResult<T>> => {
225
- const { signal, cleanup } = withHookTimeout(timeoutMs);
226
- try {
227
- return await attempt(signal, attemptNumber, requestId, timeoutMs);
228
- } catch (error) {
229
- if (!isTransientHookFetchError(error)) throw error;
230
- return { done: false };
231
- } finally {
232
- cleanup();
233
- }
234
- };
342
+ const sent = watchFirstRequestSent(requestId, clock);
343
+ const controllers: AbortController[] = [];
344
+
345
+ return new Promise<T | null>((resolve, reject) => {
346
+ let settled = false;
347
+ let running = 0;
348
+ let secondStarted = false;
349
+ let firstFailure: 'gateway' | 'transport' | null = null;
350
+ let hedgeTimer: ReturnType<typeof setTimeout> | undefined;
351
+
352
+ const finish = (value: T | null, error?: unknown, winner?: AbortController): void => {
353
+ if (settled) return;
354
+ settled = true;
355
+ clearTimeout(deadlineTimer);
356
+ clearTimeout(hedgeTimer);
357
+ sent.stop();
358
+ // Cancel whatever is still in flight; the request that answered is done.
359
+ for (const controller of controllers) if (controller !== winner) controller.abort();
360
+ if (error !== undefined) reject(error);
361
+ else resolve(value);
362
+ };
363
+ const deadlineTimer = setTimeout(() => finish(null), budgetMs);
364
+
365
+ const startSecond = (): boolean => {
366
+ if (settled || secondStarted) return secondStarted;
367
+ if (deadline - clock() < HOOK_SECOND_ATTEMPT_MIN_MS) return false;
368
+ secondStarted = true;
369
+ clearTimeout(hedgeTimer);
370
+ launch(2);
371
+ return true;
372
+ };
235
373
 
236
- const first = await run(1, Math.floor(budgetMs * HOOK_FIRST_ATTEMPT_SHARE));
237
- if (first.done) return first.value;
374
+ const launch = (attemptNumber: 1 | 2): void => {
375
+ const controller = new AbortController();
376
+ controllers.push(controller);
377
+ const now = clock();
378
+ const headers: Record<string, string> = {
379
+ [HOOK_ATTEMPT_HEADER]: String(attemptNumber),
380
+ [HOOK_REQUEST_ID_HEADER]: requestId,
381
+ [HOOK_HEDGED_HEADER]: '1',
382
+ };
383
+ // Read when the request is actually built (after the credential read or
384
+ // rotation and the conversation lock), not when the attempt starts, so
385
+ // the server's deadline does not include time already spent here.
386
+ Object.defineProperty(headers, HOOK_BUDGET_HEADER, {
387
+ enumerable: true,
388
+ get: () => String(Math.max(0, Math.floor(deadline - clock()))),
389
+ });
390
+ if (attemptNumber === 2) {
391
+ const sentAt = sent.sentAt();
392
+ headers[HOOK_FIRST_ATTEMPT_HEADER] = formatHookFirstAttempt({
393
+ state: firstFailure ?? 'pending',
394
+ sentAgoMs: sentAt === null ? null : now - sentAt,
395
+ });
396
+ }
397
+ running += 1;
398
+ void (async () => {
399
+ let outcome: HookAttemptOutcome<T>;
400
+ try {
401
+ const response = await send(headers, controller.signal);
402
+ if (isRetryableHookStatus(response.status)) outcome = { final: false, kind: 'gateway' };
403
+ else if (response.headers.get(HOOK_REPLAY_HEADER) === HOOK_REPLAY_PENDING)
404
+ outcome = { final: false, kind: 'pending' };
405
+ else if (!response.ok) outcome = { final: true, value: null };
406
+ else outcome = { final: true, value: await parse(response) };
407
+ } catch (error) {
408
+ if (!settled && !isTransientHookFetchError(error)) {
409
+ running -= 1;
410
+ finish(null, error);
411
+ return;
412
+ }
413
+ outcome = { final: false, kind: 'transport' };
414
+ }
415
+ running -= 1;
416
+ if (settled) return;
417
+ if (outcome.final) {
418
+ finish(outcome.value, undefined, controller);
419
+ return;
420
+ }
421
+ // Not an answer. The first request's failure starts the second at once;
422
+ // with no request left running there is no answer to wait for.
423
+ if (attemptNumber === 1) {
424
+ if (outcome.kind !== 'pending') firstFailure = outcome.kind;
425
+ startSecond();
426
+ }
427
+ if (running === 0) finish(null);
428
+ })();
429
+ };
238
430
 
239
- const remainingMs = budgetMs - (clock() - startedAt);
240
- if (remainingMs < HOOK_SECOND_ATTEMPT_MIN_MS) return null;
241
- const second = await run(2, Math.floor(remainingMs));
242
- return second.done ? second.value : null;
431
+ launch(1);
432
+ hedgeTimer = setTimeout(startSecond, Math.floor(budgetMs * HOOK_HEDGE_SHARE));
433
+ });
243
434
  }
@@ -0,0 +1,121 @@
1
+ import assert from 'node:assert/strict';
2
+ import { readdirSync } from 'node:fs';
3
+ import { mkdtemp, rm, utimes, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { after, test } from 'node:test';
7
+ import {
8
+ GATE_OUTBOX_CAP,
9
+ GATE_OUTBOX_MAX_AGE_MS,
10
+ GATE_OUTBOX_MAX_EVENTS,
11
+ GATE_OUTBOX_STRAY_MS,
12
+ queueGateEvent,
13
+ returnGateEvents,
14
+ takeGateEvents,
15
+ } from '../src/gateOutbox.js';
16
+
17
+ // Gate telemetry rides the session's next injections request instead of an
18
+ // awaited POST of its own (about 30% of a PreToolUse hook's process time on
19
+ // 26 October 2026). These pin the queue: claimed once, bounded, re-sendable,
20
+ // never throws.
21
+
22
+ const roots: string[] = [];
23
+ after(async () => {
24
+ await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true })));
25
+ });
26
+ async function outbox(): Promise<string> {
27
+ const root = await mkdtemp(join(tmpdir(), 'mnemonik-gate-outbox-'));
28
+ roots.push(root);
29
+ return join(root, 'session', 'gate-outbox');
30
+ }
31
+ const event = (outcome: string, at = Date.now()) => ({
32
+ tool: 'Bash',
33
+ mode: 'advisory',
34
+ outcome,
35
+ at,
36
+ });
37
+
38
+ test('queued events are taken once, oldest first, each with its own id', async () => {
39
+ const folder = await outbox();
40
+ const now = Date.now();
41
+ assert.equal(queueGateEvent(folder, event('b', now)), true);
42
+ queueGateEvent(folder, event('a', now - 10));
43
+ const taken = takeGateEvents(folder);
44
+ assert.deepEqual(
45
+ taken.map((e) => e.outcome),
46
+ ['a', 'b']
47
+ );
48
+ assert.equal(new Set(taken.map((e) => e.id)).size, 2);
49
+ // A second request of the same session finds nothing to send twice.
50
+ assert.deepEqual(takeGateEvents(folder), []);
51
+ });
52
+
53
+ test('events a request could not deliver go back, with the same ids', async () => {
54
+ const folder = await outbox();
55
+ queueGateEvent(folder, event('x'));
56
+ const taken = takeGateEvents(folder);
57
+ returnGateEvents(folder, taken);
58
+ assert.deepEqual(
59
+ takeGateEvents(folder).map((e) => e.id),
60
+ taken.map((e) => e.id)
61
+ );
62
+ });
63
+
64
+ test('an empty or missing outbox yields nothing', async () => {
65
+ assert.deepEqual(takeGateEvents(await outbox()), []);
66
+ });
67
+
68
+ test('stale and malformed events are dropped; a request carries at most the cap', async () => {
69
+ const folder = await outbox();
70
+ const now = Date.now();
71
+ queueGateEvent(folder, event('stale', now - GATE_OUTBOX_MAX_AGE_MS - 1));
72
+ for (let i = 0; i < GATE_OUTBOX_MAX_EVENTS + 5; i += 1)
73
+ queueGateEvent(folder, event(`e${i}`, now));
74
+ await writeFile(join(folder, '999999999999999-torn.json'), '{"tool":"Bash"');
75
+ const first = takeGateEvents(folder, now);
76
+ assert.equal(first.length, GATE_OUTBOX_MAX_EVENTS);
77
+ assert.ok(first.every((e) => e.outcome !== 'stale'));
78
+ const rest = takeGateEvents(folder, now);
79
+ assert.equal(first.length + rest.length, GATE_OUTBOX_MAX_EVENTS + 5);
80
+ assert.ok(GATE_OUTBOX_MAX_AGE_MS >= 24 * 60 * 60 * 1000);
81
+ });
82
+
83
+ test('a queue that cannot be written reports false instead of throwing', async () => {
84
+ // A regular file where the session folder should be: no folder can be made.
85
+ const folder = await outbox();
86
+ queueGateEvent(folder, event('x'));
87
+ const file = join(folder, 'not-a-folder');
88
+ await writeFile(file, 'x');
89
+ assert.equal(queueGateEvent(join(file, 'gate-outbox'), event('y')), false);
90
+ });
91
+
92
+ test('the outbox holds at most the cap, dropping the oldest', async () => {
93
+ // A server that stays unreachable: every request returns its events.
94
+ const folder = await outbox();
95
+ const now = Date.now();
96
+ for (let i = 0; i < GATE_OUTBOX_CAP + 20; i += 1) queueGateEvent(folder, event(`e${i}`, now + i));
97
+ const queued = readdirSync(folder).filter((name) => name.endsWith('.json'));
98
+ assert.equal(queued.length, GATE_OUTBOX_CAP);
99
+ const taken = takeGateEvents(folder, now + GATE_OUTBOX_CAP + 20);
100
+ assert.equal(taken[0]?.outcome, 'e20');
101
+ returnGateEvents(folder, taken);
102
+ queueGateEvent(folder, event('newest', now + GATE_OUTBOX_CAP + 21));
103
+ assert.equal(
104
+ readdirSync(folder).filter((name) => name.endsWith('.json')).length,
105
+ GATE_OUTBOX_CAP
106
+ );
107
+ });
108
+
109
+ test('temporary and claimed files a killed hook left are swept after ten minutes', async () => {
110
+ const folder = await outbox();
111
+ queueGateEvent(folder, event('x'));
112
+ const strayTmp = join(folder, '.000000000000001-a.json.tmp');
113
+ const strayClaim = join(folder, '.000000000000002-b.json.123.claimed');
114
+ const freshTmp = join(folder, '.000000000000003-c.json.tmp');
115
+ for (const path of [strayTmp, strayClaim, freshTmp]) await writeFile(path, '{}');
116
+ const old = new Date(Date.now() - GATE_OUTBOX_STRAY_MS - 60_000);
117
+ await utimes(strayTmp, old, old);
118
+ await utimes(strayClaim, old, old);
119
+ assert.equal(takeGateEvents(folder).length, 1);
120
+ assert.deepEqual(readdirSync(folder).sort(), ['.000000000000003-c.json.tmp']);
121
+ });