@mnemonik/shared 7.112.1 → 7.112.3
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/gitEditDetector.d.ts +85 -0
- package/dist/gitEditDetector.d.ts.map +1 -1
- package/dist/gitEditDetector.js +305 -3
- package/dist/gitEditDetector.js.map +1 -1
- package/dist/hookRuntime.d.ts +20 -0
- package/dist/hookRuntime.d.ts.map +1 -1
- package/dist/hookRuntime.js +82 -9
- package/dist/hookRuntime.js.map +1 -1
- package/dist/hookTimeouts.d.ts +36 -0
- package/dist/hookTimeouts.d.ts.map +1 -1
- package/dist/hookTimeouts.js +37 -0
- package/dist/hookTimeouts.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/projectIdentityFile.d.ts.map +1 -1
- package/dist/projectIdentityFile.js +8 -2
- package/dist/projectIdentityFile.js.map +1 -1
- package/dist/projectSetupHandoff.d.ts +9 -0
- package/dist/projectSetupHandoff.d.ts.map +1 -1
- package/dist/projectSetupHandoff.js +178 -36
- package/dist/projectSetupHandoff.js.map +1 -1
- package/dist/usageGuide.d.ts +1 -1
- package/dist/usageGuide.d.ts.map +1 -1
- package/dist/usageGuide.js +1 -2
- package/dist/usageGuide.js.map +1 -1
- package/package.json +1 -1
- package/src/gitEditDetector.ts +347 -3
- package/src/hookRuntime.ts +96 -8
- package/src/hookTimeouts.ts +43 -0
- package/src/index.ts +6 -0
- package/src/projectIdentityFile.ts +10 -2
- package/src/projectSetupHandoff.ts +202 -42
- package/src/usageGuide.ts +1 -2
- package/tests/projectIdentity.test.ts +9 -0
package/package.json
CHANGED
package/src/gitEditDetector.ts
CHANGED
|
@@ -50,8 +50,10 @@
|
|
|
50
50
|
*/
|
|
51
51
|
|
|
52
52
|
import { execFileSync } from 'node:child_process';
|
|
53
|
-
import {
|
|
54
|
-
import {
|
|
53
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
54
|
+
import { mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
55
|
+
import { dirname, isAbsolute, join } from 'node:path';
|
|
56
|
+
import { performance } from 'node:perf_hooks';
|
|
55
57
|
|
|
56
58
|
/** Bounded so a pathological repo cannot stall the hook's PostToolUse turn. */
|
|
57
59
|
const GIT_REV_PARSE_TIMEOUT_MS = 400;
|
|
@@ -161,6 +163,30 @@ function committedPaths(cwd: string, root: string, from: string | undefined, to:
|
|
|
161
163
|
.map((name) => join(root, name));
|
|
162
164
|
}
|
|
163
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Replace `file` whole: write a temporary file beside it, then rename it into
|
|
168
|
+
* place. Hook processes for one session run concurrently (parallel tool calls)
|
|
169
|
+
* and each rewrites the snapshot and the retry retryState; a plain writeFileSync
|
|
170
|
+
* truncates first, so a reader could see an empty or partial file and two
|
|
171
|
+
* writers could interleave into corrupt JSON. A rename is atomic on the same
|
|
172
|
+
* file system: a reader sees the old file or the new one. Concurrent writers
|
|
173
|
+
* still race (the last rename wins), which costs at most a re-sent report.
|
|
174
|
+
*/
|
|
175
|
+
function writeFileWhole(file: string, data: string): void {
|
|
176
|
+
const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
177
|
+
try {
|
|
178
|
+
writeFileSync(temporary, data, { mode: 0o600 });
|
|
179
|
+
renameSync(temporary, file);
|
|
180
|
+
} catch (error) {
|
|
181
|
+
try {
|
|
182
|
+
unlinkSync(temporary);
|
|
183
|
+
} catch {
|
|
184
|
+
// Never written, or already renamed.
|
|
185
|
+
}
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
164
190
|
function readSnapshot(snapshotFile: string): DirtySnapshot | null {
|
|
165
191
|
try {
|
|
166
192
|
const parsed = JSON.parse(readFileSync(snapshotFile, 'utf8')) as
|
|
@@ -189,7 +215,7 @@ function writeRoot(snapshotFile: string, root: string, entry: RootSnapshot): voi
|
|
|
189
215
|
roots: total > MAX_SNAPSHOT_PATHS ? { [root]: entry } : roots,
|
|
190
216
|
};
|
|
191
217
|
mkdirSync(dirname(snapshotFile), { recursive: true, mode: 0o700 });
|
|
192
|
-
|
|
218
|
+
writeFileWhole(snapshotFile, JSON.stringify(snapshot));
|
|
193
219
|
} catch {
|
|
194
220
|
// Best-effort: a missing snapshot degrades to "report nothing", never to a
|
|
195
221
|
// wrong report.
|
|
@@ -259,3 +285,321 @@ export function addPathsToGitDirtySnapshot(snapshotFile: string, paths: string[]
|
|
|
259
285
|
writeRoot(snapshotFile, root, entry);
|
|
260
286
|
}
|
|
261
287
|
}
|
|
288
|
+
|
|
289
|
+
/** Largest file whose content is hashed for an edit fingerprint, in bytes. */
|
|
290
|
+
const EDIT_FINGERPRINT_MAX_BYTES = 8 * 1024 * 1024;
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* The fingerprint a host sends with an edit report: the first 32 hex chars of
|
|
294
|
+
* the sha256 of the file's current content, 'absent' when the file does not
|
|
295
|
+
* exist, undefined when it cannot be read, is not a regular file, or exceeds
|
|
296
|
+
* EDIT_FINGERPRINT_MAX_BYTES (the report then carries none and counts as
|
|
297
|
+
* before).
|
|
298
|
+
*
|
|
299
|
+
* Why: a report the server recorded but answered after the host's budget
|
|
300
|
+
* stays unconfirmed here and the next carrier's diff re-sends it. The server
|
|
301
|
+
* compares the fingerprint with the last one it recorded for the path, so the
|
|
302
|
+
* re-send of unchanged content is not counted as a second edit, while any
|
|
303
|
+
* further edit changes the content and is.
|
|
304
|
+
*/
|
|
305
|
+
export function editFingerprint(path: string, cwd = ''): string | undefined {
|
|
306
|
+
const absolute = isAbsolute(path) ? path : join(cwd, path);
|
|
307
|
+
try {
|
|
308
|
+
const stat = statSync(absolute);
|
|
309
|
+
if (!stat.isFile() || stat.size > EDIT_FINGERPRINT_MAX_BYTES) return undefined;
|
|
310
|
+
return createHash('sha256').update(readFileSync(absolute)).digest('hex').slice(0, 32);
|
|
311
|
+
} catch (error) {
|
|
312
|
+
return (error as NodeJS.ErrnoException).code === 'ENOENT' ? 'absent' : undefined;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// - Bounded edit-report retry -
|
|
317
|
+
//
|
|
318
|
+
// An edit report the server did not confirm stays pending so a later event
|
|
319
|
+
// can deliver it (CQ-033). Unbounded, that meant every later event re-sent
|
|
320
|
+
// every pending path one by one with a 1.5 s timeout each, forever: a hung or
|
|
321
|
+
// refusing server stalled each hook by ~1.5 s per pending path, past the
|
|
322
|
+
// host's hook timeout (Codex and Grok kill the hook at 5 s; Cursor at 30 s,
|
|
323
|
+
// where it also delayed the catastrophic-command deny check), and permanent
|
|
324
|
+
// refusals were retried for the life of the session. One policy for every
|
|
325
|
+
// host bounds it:
|
|
326
|
+
//
|
|
327
|
+
// - one call per hook event, inside a total time budget well under the
|
|
328
|
+
// shortest host hook timeout; attempts run a few at a time, each capped by
|
|
329
|
+
// the per-attempt timeout and by what is left of the budget;
|
|
330
|
+
// - per path: the first failure retries on the next event, later failures
|
|
331
|
+
// back off exponentially; after EDIT_REPORT_RETRY.maxAttempts attempts, or
|
|
332
|
+
// EDIT_REPORT_RETRY.expiryMs after the first, the path is dropped;
|
|
333
|
+
// - a permanent refusal (4xx other than 408/429, or `{ ok: false }`) drops
|
|
334
|
+
// the path at once; an answer marked `retryable: true` is not a refusal:
|
|
335
|
+
// the server could not check the credential (a database blip) or the hook's
|
|
336
|
+
// own credential could not be used this time (fetchWithHookCredential's 503
|
|
337
|
+
// marker), so it retries like any other transient failure; 429 pauses every
|
|
338
|
+
// report until its Retry-After; an event whose every attempt timed out
|
|
339
|
+
// pauses reports briefly (the server hangs);
|
|
340
|
+
// - a dropped path joins the dirty snapshot exactly as a confirmed one does,
|
|
341
|
+
// so no later diff re-reports it, and the drop is logged at debug level.
|
|
342
|
+
//
|
|
343
|
+
// Pending state lives beside the dirty snapshot (`<snapshotFile>.retries`),
|
|
344
|
+
// per session. The host's caller runs its safety/deny logic before this.
|
|
345
|
+
|
|
346
|
+
export const EDIT_REPORT_RETRY = {
|
|
347
|
+
/** Total time one hook event may spend on edit reports. */
|
|
348
|
+
budgetMs: 1_500,
|
|
349
|
+
/** One report's own ceiling (the shared POST_TOOL_TIMEOUT_MS). */
|
|
350
|
+
attemptTimeoutMs: 1_500,
|
|
351
|
+
/** Reports in flight at once. */
|
|
352
|
+
concurrency: 4,
|
|
353
|
+
maxAttempts: 5,
|
|
354
|
+
/** Backoff after the second failure; doubles per failure after that. */
|
|
355
|
+
backoffBaseMs: 5_000,
|
|
356
|
+
backoffMaxMs: 5 * 60_000,
|
|
357
|
+
/** A path pending longer than this since its first attempt is dropped. */
|
|
358
|
+
expiryMs: 30 * 60_000,
|
|
359
|
+
/** Pause after an event in which every attempt timed out. */
|
|
360
|
+
hungServerPauseMs: 15_000,
|
|
361
|
+
rateLimitDefaultMs: 30_000,
|
|
362
|
+
rateLimitMaxMs: 5 * 60_000,
|
|
363
|
+
/** Oldest pending paths beyond this are dropped. */
|
|
364
|
+
maxPending: 200,
|
|
365
|
+
} as const;
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* The edit-report budget a hook event can still afford (CQ-033 review). The
|
|
369
|
+
* event's own work runs after its edit reports and the host kills the hook at
|
|
370
|
+
* its installed timeout, so the reports get only what is left of `deadlineMs`
|
|
371
|
+
* (the host timeout less a safety margin, measured from process start) after
|
|
372
|
+
* the time this process has already spent and `reserveMs`, the worst case of
|
|
373
|
+
* the event's own work still to come; capped at EDIT_REPORT_RETRY.budgetMs.
|
|
374
|
+
* Zero means send nothing now: every path stays pending for a later, lighter
|
|
375
|
+
* event. The per-event sums are pinned by tests/HookEventBudgets.test.ts.
|
|
376
|
+
*/
|
|
377
|
+
export function editReportBudgetMs(
|
|
378
|
+
deadlineMs: number,
|
|
379
|
+
reserveMs: number,
|
|
380
|
+
elapsedMs: number = performance.now()
|
|
381
|
+
): number {
|
|
382
|
+
return Math.max(
|
|
383
|
+
0,
|
|
384
|
+
Math.min(EDIT_REPORT_RETRY.budgetMs, Math.floor(deadlineMs - reserveMs - elapsedMs))
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** What one edit report achieved, as the sending host classifies it. */
|
|
389
|
+
export type EditReportResult =
|
|
390
|
+
| { kind: 'confirmed' }
|
|
391
|
+
| { kind: 'transient'; detail: string; timedOut?: boolean }
|
|
392
|
+
| { kind: 'permanent'; detail: string }
|
|
393
|
+
| { kind: 'rate_limited'; retryAfterMs?: number };
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Classify an HTTP answer to track-ide-edit. `body` is the parsed JSON, if any.
|
|
397
|
+
* `retryable: true` marks a failure that says nothing lasting (see above); an
|
|
398
|
+
* older server never sends it, so its `{ ok: false }` stays permanent.
|
|
399
|
+
*/
|
|
400
|
+
export function classifyEditReportResponse(
|
|
401
|
+
status: number,
|
|
402
|
+
body: unknown,
|
|
403
|
+
retryAfter?: string | null
|
|
404
|
+
): EditReportResult {
|
|
405
|
+
const retryable = (body as { retryable?: unknown } | null | undefined)?.retryable === true;
|
|
406
|
+
if (status >= 200 && status < 300) {
|
|
407
|
+
const ok = (body as { ok?: unknown } | null | undefined)?.ok;
|
|
408
|
+
if (ok === true) return { kind: 'confirmed' };
|
|
409
|
+
if (ok === false && retryable) return { kind: 'transient', detail: 'ok:false retryable' };
|
|
410
|
+
if (ok === false) return { kind: 'permanent', detail: 'ok:false' };
|
|
411
|
+
return { kind: 'transient', detail: 'unreadable response' };
|
|
412
|
+
}
|
|
413
|
+
if (status === 429) {
|
|
414
|
+
const seconds = retryAfter ? Number(retryAfter) : NaN;
|
|
415
|
+
return {
|
|
416
|
+
kind: 'rate_limited',
|
|
417
|
+
...(Number.isFinite(seconds) && seconds >= 0 ? { retryAfterMs: seconds * 1000 } : {}),
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
if (status >= 400 && status < 500 && status !== 408 && !retryable) {
|
|
421
|
+
return { kind: 'permanent', detail: `HTTP ${status}` };
|
|
422
|
+
}
|
|
423
|
+
return { kind: 'transient', detail: `HTTP ${status}` };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
interface PendingReport {
|
|
427
|
+
attempts: number;
|
|
428
|
+
firstAt: number;
|
|
429
|
+
nextAt: number;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
interface RetryState {
|
|
433
|
+
v: 1;
|
|
434
|
+
pending: Record<string, PendingReport>;
|
|
435
|
+
pausedUntil?: number;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function retryStateFile(snapshotFile: string): string {
|
|
439
|
+
return `${snapshotFile}.retries`;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function readRetryState(snapshotFile: string): RetryState {
|
|
443
|
+
if (!snapshotFile) return { v: 1, pending: {} };
|
|
444
|
+
try {
|
|
445
|
+
const parsed = JSON.parse(readFileSync(retryStateFile(snapshotFile), 'utf8')) as RetryState;
|
|
446
|
+
if (parsed?.v === 1 && typeof parsed.pending === 'object' && parsed.pending !== null) {
|
|
447
|
+
return parsed;
|
|
448
|
+
}
|
|
449
|
+
} catch {
|
|
450
|
+
// Missing or unreadable: start empty.
|
|
451
|
+
}
|
|
452
|
+
return { v: 1, pending: {} };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function writeRetryState(snapshotFile: string, retryState: RetryState): void {
|
|
456
|
+
if (!snapshotFile) return;
|
|
457
|
+
try {
|
|
458
|
+
mkdirSync(dirname(snapshotFile), { recursive: true, mode: 0o700 });
|
|
459
|
+
writeFileWhole(retryStateFile(snapshotFile), JSON.stringify(retryState));
|
|
460
|
+
} catch {
|
|
461
|
+
// Best-effort: a lost retryState costs at most one extra attempt per path.
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Paths whose report is pending and due now (retry on any hook event). */
|
|
466
|
+
export function pendingEditReports(snapshotFile: string, now = Date.now()): string[] {
|
|
467
|
+
const retryState = readRetryState(snapshotFile);
|
|
468
|
+
if ((retryState.pausedUntil ?? 0) > now) return [];
|
|
469
|
+
return Object.entries(retryState.pending)
|
|
470
|
+
.filter(([, entry]) => entry.nextAt <= now)
|
|
471
|
+
.map(([path]) => path);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async function attemptReport(
|
|
475
|
+
path: string,
|
|
476
|
+
send: (path: string, signal: AbortSignal) => Promise<EditReportResult>,
|
|
477
|
+
timeoutMs: number
|
|
478
|
+
): Promise<EditReportResult> {
|
|
479
|
+
const controller = new AbortController();
|
|
480
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
481
|
+
const timedOut = new Promise<EditReportResult>((resolveTimeout) => {
|
|
482
|
+
timer = setTimeout(() => {
|
|
483
|
+
controller.abort();
|
|
484
|
+
resolveTimeout({ kind: 'transient', detail: 'timeout', timedOut: true });
|
|
485
|
+
}, timeoutMs);
|
|
486
|
+
});
|
|
487
|
+
try {
|
|
488
|
+
return await Promise.race([
|
|
489
|
+
send(path, controller.signal).catch((error: unknown): EditReportResult => ({
|
|
490
|
+
kind: 'transient',
|
|
491
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
492
|
+
timedOut: controller.signal.aborted,
|
|
493
|
+
})),
|
|
494
|
+
timedOut,
|
|
495
|
+
]);
|
|
496
|
+
} finally {
|
|
497
|
+
clearTimeout(timer);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Report edited paths under the bounded retry policy: this event's new paths
|
|
503
|
+
* plus every pending path now due. Call once per hook event (pass `paths: []`
|
|
504
|
+
* to deliver only pending ones). Confirmed and dropped paths join the dirty
|
|
505
|
+
* snapshot; the rest stay pending for a later event.
|
|
506
|
+
*/
|
|
507
|
+
export async function reportEditsWithinBudget(options: {
|
|
508
|
+
snapshotFile: string;
|
|
509
|
+
paths: readonly string[];
|
|
510
|
+
send: (path: string, signal: AbortSignal) => Promise<EditReportResult>;
|
|
511
|
+
budgetMs?: number;
|
|
512
|
+
now?: () => number;
|
|
513
|
+
log?: (message: string) => void;
|
|
514
|
+
}): Promise<{ confirmed: string[]; dropped: string[]; deferred: string[] }> {
|
|
515
|
+
const now = options.now ?? Date.now;
|
|
516
|
+
// Debug level: silent unless MNEMONIK_HOOK_DEBUG is set (hook stderr can
|
|
517
|
+
// reach the host's UI).
|
|
518
|
+
const log =
|
|
519
|
+
options.log ??
|
|
520
|
+
((message: string) => {
|
|
521
|
+
if (process.env.MNEMONIK_HOOK_DEBUG) process.stderr.write(`${message}\n`);
|
|
522
|
+
});
|
|
523
|
+
const policy = EDIT_REPORT_RETRY;
|
|
524
|
+
const start = now();
|
|
525
|
+
const deadline = start + (options.budgetMs ?? policy.budgetMs);
|
|
526
|
+
const retryState = readRetryState(options.snapshotFile);
|
|
527
|
+
const confirmed: string[] = [];
|
|
528
|
+
const dropped: string[] = [];
|
|
529
|
+
const drop = (path: string, why: string): void => {
|
|
530
|
+
delete retryState.pending[path];
|
|
531
|
+
dropped.push(path);
|
|
532
|
+
log(`mnemonik: edit report for ${path} dropped (${why})`);
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
for (const [path, entry] of Object.entries(retryState.pending)) {
|
|
536
|
+
if (start - entry.firstAt > policy.expiryMs) drop(path, 'expired');
|
|
537
|
+
}
|
|
538
|
+
for (const path of options.paths) {
|
|
539
|
+
retryState.pending[path] ??= { attempts: 0, firstAt: start, nextAt: start };
|
|
540
|
+
}
|
|
541
|
+
const due = [...new Set([...options.paths, ...Object.keys(retryState.pending)])].filter(
|
|
542
|
+
(path) => retryState.pending[path] && retryState.pending[path].nextAt <= start
|
|
543
|
+
);
|
|
544
|
+
|
|
545
|
+
let queue = (retryState.pausedUntil ?? 0) > start ? [] : due;
|
|
546
|
+
while (queue.length > 0) {
|
|
547
|
+
const remaining = deadline - now();
|
|
548
|
+
if (remaining <= 0) break;
|
|
549
|
+
const wave = queue.slice(0, policy.concurrency);
|
|
550
|
+
queue = queue.slice(policy.concurrency);
|
|
551
|
+
const timeout = Math.min(policy.attemptTimeoutMs, remaining);
|
|
552
|
+
const results = await Promise.all(
|
|
553
|
+
wave.map(async (path) => [path, await attemptReport(path, options.send, timeout)] as const)
|
|
554
|
+
);
|
|
555
|
+
const at = now();
|
|
556
|
+
let rateLimited = false;
|
|
557
|
+
for (const [path, result] of results) {
|
|
558
|
+
const entry = retryState.pending[path];
|
|
559
|
+
if (!entry) continue;
|
|
560
|
+
if (result.kind === 'confirmed') {
|
|
561
|
+
delete retryState.pending[path];
|
|
562
|
+
confirmed.push(path);
|
|
563
|
+
} else if (result.kind === 'permanent') {
|
|
564
|
+
drop(path, result.detail);
|
|
565
|
+
} else if (result.kind === 'rate_limited') {
|
|
566
|
+
rateLimited = true;
|
|
567
|
+
const pause = Math.min(
|
|
568
|
+
result.retryAfterMs ?? policy.rateLimitDefaultMs,
|
|
569
|
+
policy.rateLimitMaxMs
|
|
570
|
+
);
|
|
571
|
+
retryState.pausedUntil = Math.max(retryState.pausedUntil ?? 0, at + pause);
|
|
572
|
+
entry.nextAt = retryState.pausedUntil;
|
|
573
|
+
} else {
|
|
574
|
+
entry.attempts += 1;
|
|
575
|
+
if (entry.attempts >= policy.maxAttempts) {
|
|
576
|
+
drop(path, `${entry.attempts} attempts, last: ${result.detail}`);
|
|
577
|
+
} else {
|
|
578
|
+
entry.nextAt =
|
|
579
|
+
entry.attempts <= 1
|
|
580
|
+
? at
|
|
581
|
+
: at +
|
|
582
|
+
Math.min(policy.backoffBaseMs * 2 ** (entry.attempts - 2), policy.backoffMaxMs);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
if (rateLimited) break;
|
|
587
|
+
if (results.every(([, result]) => result.kind === 'transient' && result.timedOut)) {
|
|
588
|
+
retryState.pausedUntil = at + policy.hungServerPauseMs;
|
|
589
|
+
break;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const pendingPaths = Object.keys(retryState.pending);
|
|
594
|
+
if (pendingPaths.length > policy.maxPending) {
|
|
595
|
+
pendingPaths
|
|
596
|
+
.sort((a, b) => (retryState.pending[a]?.firstAt ?? 0) - (retryState.pending[b]?.firstAt ?? 0))
|
|
597
|
+
.slice(0, pendingPaths.length - policy.maxPending)
|
|
598
|
+
.forEach((path) => drop(path, 'too many pending reports'));
|
|
599
|
+
}
|
|
600
|
+
if ((retryState.pausedUntil ?? 0) <= now()) delete retryState.pausedUntil;
|
|
601
|
+
addPathsToGitDirtySnapshot(options.snapshotFile, [...confirmed, ...dropped]);
|
|
602
|
+
writeRetryState(options.snapshotFile, retryState);
|
|
603
|
+
const deferred = Object.keys(retryState.pending);
|
|
604
|
+
return { confirmed, dropped, deferred };
|
|
605
|
+
}
|
package/src/hookRuntime.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { protectWindowsDirectory, type Execute } from './runtimeSigners.js';
|
|
2
|
-
import { stderr } from 'node:process';
|
|
2
|
+
import { stderr, stdin } from 'node:process';
|
|
3
|
+
import { HOOK_STDIN_TIMEOUT_MS } from './hookTimeouts.js';
|
|
3
4
|
import { resolveProjectIdentity, type ProjectIdentityResolution } from './repositoryRoot.js';
|
|
4
5
|
|
|
5
6
|
export interface ProjectIdentity {
|
|
@@ -8,18 +9,47 @@ export interface ProjectIdentity {
|
|
|
8
9
|
projectRoot: string;
|
|
9
10
|
}
|
|
10
11
|
|
|
12
|
+
/**
|
|
13
|
+
* One resolution per directory per process. A hook process serves one event,
|
|
14
|
+
* and its handlers resolve the same cwd several times in sequence (each
|
|
15
|
+
* request builder, each gate report); every resolution runs `git rev-parse`
|
|
16
|
+
* with a 2 s timeout, so on a slow or hung git the repeats alone could pass
|
|
17
|
+
* the host's 5 s hook timeout. Only hook runtimes call this (short-lived
|
|
18
|
+
* processes); project setup clears it after it writes an identity.
|
|
19
|
+
*/
|
|
20
|
+
const identityCache = new Map<string, Promise<ProjectIdentity | null>>();
|
|
21
|
+
|
|
22
|
+
/** Forget cached resolutions (after project setup wrote a `.mnemonik.json`). */
|
|
23
|
+
export function clearProjectIdentityCache(): void {
|
|
24
|
+
identityCache.clear();
|
|
25
|
+
}
|
|
26
|
+
|
|
11
27
|
export async function findProjectIdentity(
|
|
12
28
|
startCwd: string,
|
|
13
29
|
options: { allowNestedInherit?: boolean; maxDepth?: number } = {}
|
|
14
30
|
): Promise<ProjectIdentity | null> {
|
|
15
31
|
if (!startCwd) return null;
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
32
|
+
const key = JSON.stringify([
|
|
33
|
+
startCwd,
|
|
34
|
+
options.allowNestedInherit ?? null,
|
|
35
|
+
options.maxDepth ?? null,
|
|
36
|
+
]);
|
|
37
|
+
let pending = identityCache.get(key);
|
|
38
|
+
if (!pending) {
|
|
39
|
+
pending = findProjectIdentityDetailed(startCwd, options).then((result) =>
|
|
40
|
+
result.kind === 'ok'
|
|
41
|
+
? {
|
|
42
|
+
projectId: result.identity.projectId,
|
|
43
|
+
projectName: result.identity.projectName,
|
|
44
|
+
projectRoot: result.root,
|
|
45
|
+
}
|
|
46
|
+
: null
|
|
47
|
+
);
|
|
48
|
+
identityCache.set(key, pending);
|
|
49
|
+
// A failure is not an answer: the next caller resolves afresh.
|
|
50
|
+
pending.catch(() => identityCache.delete(key));
|
|
51
|
+
}
|
|
52
|
+
return pending;
|
|
23
53
|
}
|
|
24
54
|
|
|
25
55
|
export async function findProjectIdentityDetailed(
|
|
@@ -29,6 +59,64 @@ export async function findProjectIdentityDetailed(
|
|
|
29
59
|
return resolveProjectIdentity(startCwd, options);
|
|
30
60
|
}
|
|
31
61
|
|
|
62
|
+
/** Largest hook payload read from stdin; a bigger one fails open. */
|
|
63
|
+
export const HOOK_STDIN_MAX_BYTES = 1_048_576;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Read a hook's stdin payload, bounded in size and in time. Resolves the text
|
|
67
|
+
* (a leading BOM removed) when the host closes stdin, or when `timeoutMs`
|
|
68
|
+
* passes first: then with whatever arrived, which the caller parses like any
|
|
69
|
+
* other payload (a partial one fails to parse and the hook fails open).
|
|
70
|
+
* Resolves null past `maxBytes` or on a stream error. After the timeout or
|
|
71
|
+
* the cap the stream is destroyed, so an unclosed pipe no longer holds the
|
|
72
|
+
* process open (Grok's hook returns without calling exit).
|
|
73
|
+
*/
|
|
74
|
+
export function readHookStdin(
|
|
75
|
+
options: {
|
|
76
|
+
stream?: NodeJS.ReadableStream & { destroy?: () => void };
|
|
77
|
+
timeoutMs?: number;
|
|
78
|
+
maxBytes?: number;
|
|
79
|
+
} = {}
|
|
80
|
+
): Promise<string | null> {
|
|
81
|
+
const stream = options.stream ?? stdin;
|
|
82
|
+
const maxBytes = options.maxBytes ?? HOOK_STDIN_MAX_BYTES;
|
|
83
|
+
return new Promise((resolve) => {
|
|
84
|
+
const chunks: Buffer[] = [];
|
|
85
|
+
let bytes = 0;
|
|
86
|
+
let settled = false;
|
|
87
|
+
const text = () =>
|
|
88
|
+
Buffer.concat(chunks)
|
|
89
|
+
.toString('utf8')
|
|
90
|
+
.replace(/^\uFEFF/, '');
|
|
91
|
+
const finish = (value: string | null, release: boolean) => {
|
|
92
|
+
if (settled) return;
|
|
93
|
+
settled = true;
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
stream.removeListener('data', onData);
|
|
96
|
+
stream.removeListener('end', onEnd);
|
|
97
|
+
if (release) {
|
|
98
|
+
stream.pause();
|
|
99
|
+
stream.destroy?.();
|
|
100
|
+
}
|
|
101
|
+
resolve(value);
|
|
102
|
+
};
|
|
103
|
+
const onData = (chunk: Buffer | string) => {
|
|
104
|
+
const buffer = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk;
|
|
105
|
+
bytes += buffer.length;
|
|
106
|
+
if (bytes > maxBytes) return finish(null, true);
|
|
107
|
+
chunks.push(buffer);
|
|
108
|
+
};
|
|
109
|
+
const onEnd = () => finish(text(), false);
|
|
110
|
+
const timer = setTimeout(
|
|
111
|
+
() => finish(text(), true),
|
|
112
|
+
options.timeoutMs ?? HOOK_STDIN_TIMEOUT_MS
|
|
113
|
+
);
|
|
114
|
+
stream.on('data', onData);
|
|
115
|
+
stream.on('end', onEnd);
|
|
116
|
+
stream.on('error', () => finish(null, false));
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
32
120
|
let mismatchEmitted = false;
|
|
33
121
|
|
|
34
122
|
export async function parseHookResponse<T>(response: Response): Promise<T> {
|
package/src/hookTimeouts.ts
CHANGED
|
@@ -47,6 +47,49 @@ export const POST_TOOL_TIMEOUT_MS = 1500;
|
|
|
47
47
|
*/
|
|
48
48
|
export const MCP_PRECHECK_TIMEOUT_MS = 1000;
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* PreCompact snapshot POST budget (Claude Code and Cursor; Grok keeps a pinned
|
|
52
|
+
* copy). Independent of FETCH_TIMEOUT_MS: the snapshot is the one hook call
|
|
53
|
+
* whose server work is a write the compaction depends on.
|
|
54
|
+
*/
|
|
55
|
+
export const PRECOMPACT_TIMEOUT_MS = 4000;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The timeout each host is installed with for every Mnemonik hook event: the
|
|
59
|
+
* host kills the hook at this. The installers write these values (seconds =
|
|
60
|
+
* value / 1000), so a hook event's worst case is checked against the number
|
|
61
|
+
* the host actually enforces (tests/HookEventBudgets.test.ts).
|
|
62
|
+
*/
|
|
63
|
+
export const HOST_HOOK_TIMEOUT_MS = {
|
|
64
|
+
claude_code: 5000,
|
|
65
|
+
copilot: 5000,
|
|
66
|
+
codex: 5000,
|
|
67
|
+
grok: 5000,
|
|
68
|
+
cursor: 30_000,
|
|
69
|
+
} as const;
|
|
70
|
+
export type HookHost = keyof typeof HOST_HOOK_TIMEOUT_MS;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Time every hook event keeps free below its host's timeout: Node start-up,
|
|
74
|
+
* project identity on a healthy git, local file I/O, writing the answer.
|
|
75
|
+
* Reading stdin has its own bound below. An event's summed worst case must
|
|
76
|
+
* fit the rest.
|
|
77
|
+
*/
|
|
78
|
+
export const HOOK_TIMEOUT_MARGIN_MS = 500;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The longest a hook waits for its host to finish writing and close stdin
|
|
82
|
+
* (readHookStdin). Hosts write the payload at spawn and close the pipe; one
|
|
83
|
+
* that never closes it would otherwise hold the hook until the host kills it.
|
|
84
|
+
* On expiry the hook works with what arrived, or fails open.
|
|
85
|
+
*/
|
|
86
|
+
export const HOOK_STDIN_TIMEOUT_MS = 500;
|
|
87
|
+
|
|
88
|
+
/** The latest an event's own work may run to, measured from process start. */
|
|
89
|
+
export function hookDeadlineMs(host: HookHost): number {
|
|
90
|
+
return HOST_HOOK_TIMEOUT_MS[host] - HOOK_TIMEOUT_MARGIN_MS - HOOK_STDIN_TIMEOUT_MS;
|
|
91
|
+
}
|
|
92
|
+
|
|
50
93
|
/**
|
|
51
94
|
* Spawn an `AbortController` tied to a timeout. Returns the signal plus a
|
|
52
95
|
* `cleanup` function the caller MUST invoke (in `finally`) to clear the
|
package/src/index.ts
CHANGED
|
@@ -59,6 +59,12 @@ export {
|
|
|
59
59
|
TELEMETRY_TIMEOUT_MS,
|
|
60
60
|
POST_TOOL_TIMEOUT_MS,
|
|
61
61
|
MCP_PRECHECK_TIMEOUT_MS,
|
|
62
|
+
PRECOMPACT_TIMEOUT_MS,
|
|
63
|
+
HOST_HOOK_TIMEOUT_MS,
|
|
64
|
+
HOOK_TIMEOUT_MARGIN_MS,
|
|
65
|
+
HOOK_STDIN_TIMEOUT_MS,
|
|
66
|
+
hookDeadlineMs,
|
|
67
|
+
type HookHost,
|
|
62
68
|
withHookTimeout,
|
|
63
69
|
} from './hookTimeouts.js';
|
|
64
70
|
export {
|
|
@@ -98,10 +98,18 @@ export async function readIdentityFile(
|
|
|
98
98
|
const text = await readFile(path, 'utf8');
|
|
99
99
|
if (options.selectedRoot) {
|
|
100
100
|
// A ticked folder with configuration but no identity is a new project.
|
|
101
|
-
// Keep invalid identities
|
|
101
|
+
// Keep invalid identities, non-regular files and any file that names a
|
|
102
|
+
// schema other than 1 on the strict refusal path: a later schema may carry
|
|
103
|
+
// its identity under other keys, and setup must not replace it.
|
|
102
104
|
try {
|
|
103
105
|
const value: unknown = JSON.parse(text);
|
|
104
|
-
if (
|
|
106
|
+
if (
|
|
107
|
+
value &&
|
|
108
|
+
typeof value === 'object' &&
|
|
109
|
+
!Array.isArray(value) &&
|
|
110
|
+
!('projectId' in value) &&
|
|
111
|
+
(!('schemaVersion' in value) || value.schemaVersion === 1)
|
|
112
|
+
)
|
|
105
113
|
return { kind: 'absent' };
|
|
106
114
|
} catch {
|
|
107
115
|
/* The strict parser supplies the diagnostic. */
|