@ngockhoale/ukit 3.4.20 → 3.4.21
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/CHANGELOG.md +13 -0
- package/package.json +1 -1
- package/src/core/output/index.js +13 -3
- package/template_project/.claude/ukit/index/route-task.mjs +11 -0
- package/template_project/.claude/ukit/index/unic-decision.mjs +122 -3
- package/template_project/.claude/ukit/runtime/compact-threshold.mjs +148 -44
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +10 -3
- package/template_project/.claude/ukit/runtime/hook-chain-runner.mjs +22 -15
- package/template_project/.claude/ukit/runtime/hook-payload-store.mjs +22 -8
- package/template_project/.claude/ukit/runtime/hook-telemetry.mjs +10 -3
- package/template_project/.claude/ukit/runtime/hook-telemetry.sh +14 -1
- package/template_project/.claude/ukit/runtime/output-compression.mjs +13 -3
- package/template_project/.omp/hooks/pre/ukit-bridge.js +9 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to UKit are documented here.
|
|
4
4
|
|
|
5
|
+
## 3.4.21 - 2026-10-07
|
|
6
|
+
|
|
7
|
+
**Hook runtime no longer burns CPU/disk on every tool call** (five measured bugs, C95).
|
|
8
|
+
- Retention sweeps (tee cache, hook-latency telemetry, execution ledger, hook payloads, omp hook errors) only
|
|
9
|
+
looked at the first 128 directory entries, so old files past that window were never pruned and the
|
|
10
|
+
over-cap rule deleted newer files instead. The scan window now rotates.
|
|
11
|
+
- Chained `.sh` hooks no longer each spawn their own `node hook-telemetry.mjs --finish`; `hook-chain-runner.mjs`
|
|
12
|
+
writes the per-step rows (an Edit chain went from 11 node spawns to 5, same telemetry rows).
|
|
13
|
+
- `unic-decision` now honours its persisted circuit breaker (closed/open/half-open, fail-open): a rejected key
|
|
14
|
+
or dead endpoint no longer costs a failed call on every prompt.
|
|
15
|
+
- The context hard-cap gate probes the transcript incrementally instead of re-reading the last 4 MB and
|
|
16
|
+
rewriting `compact-pressure.json` on ~95% of calls; invalid persisted probe state falls back to a full scan.
|
|
17
|
+
|
|
5
18
|
## 3.4.20 - 2026-10-06
|
|
6
19
|
|
|
7
20
|
**`ukit update` now displays the NEW version.** The running process is the old code, so the
|
package/package.json
CHANGED
package/src/core/output/index.js
CHANGED
|
@@ -386,6 +386,7 @@ export async function sweepTeeCache(dir, {
|
|
|
386
386
|
maxFiles = TEE_MAX_FILES,
|
|
387
387
|
maxEntries = TEE_SWEEP_MAX_ENTRIES,
|
|
388
388
|
maxRemovals = TEE_SWEEP_MAX_REMOVALS,
|
|
389
|
+
random = Math.random,
|
|
389
390
|
} = {}) {
|
|
390
391
|
let dirents;
|
|
391
392
|
try {
|
|
@@ -404,8 +405,17 @@ export async function sweepTeeCache(dir, {
|
|
|
404
405
|
const cutoff = now() - maxAgeMs;
|
|
405
406
|
const entries = [];
|
|
406
407
|
let scanned = 0;
|
|
407
|
-
|
|
408
|
-
|
|
408
|
+
// Circular window at a random start so expired files beyond the first
|
|
409
|
+
// maxEntries readdir names are still reached (BUG-1: the head slice never
|
|
410
|
+
// age-pruned the tail). Non-finite random() falls back to start 0.
|
|
411
|
+
const L = eligibleNames.length;
|
|
412
|
+
const r = random();
|
|
413
|
+
const start = L > maxEntries
|
|
414
|
+
? Math.min(L - 1, Math.max(0, Math.floor((Number.isFinite(r) ? r : 0) * L)))
|
|
415
|
+
: 0;
|
|
416
|
+
const windowSize = Math.min(maxEntries, L);
|
|
417
|
+
for (let i = 0; i < windowSize; i += 1) {
|
|
418
|
+
const name = eligibleNames[(start + i) % L];
|
|
409
419
|
scanned += 1;
|
|
410
420
|
try {
|
|
411
421
|
const stats = await fs.stat(path.join(dir, name));
|
|
@@ -439,7 +449,7 @@ export async function maybeSweepTeeCache(dir, {
|
|
|
439
449
|
? Math.min(1, Math.max(0, probability))
|
|
440
450
|
: teeSweepProbabilityFromEnv();
|
|
441
451
|
if (random() >= p) return { sampled: false, scanned: 0, removed: 0 };
|
|
442
|
-
return sweepTeeCache(dir, options);
|
|
452
|
+
return sweepTeeCache(dir, { ...options, random });
|
|
443
453
|
}
|
|
444
454
|
|
|
445
455
|
async function persistRawOutput(projectRoot, {
|
|
@@ -1771,7 +1771,18 @@ export async function runRouteDecisionShadow({
|
|
|
1771
1771
|
|
|
1772
1772
|
let result = null;
|
|
1773
1773
|
const startedAt = now();
|
|
1774
|
+
// FR-004: skip the CLI spawn while the persisted decision circuit is open
|
|
1775
|
+
// (half-open does not skip — the CLI claims the probe). Any failure = closed.
|
|
1776
|
+
let circuitOpen = false;
|
|
1774
1777
|
try {
|
|
1778
|
+
const { isDecisionCircuitOpen } = await import('./unic-decision.mjs');
|
|
1779
|
+
circuitOpen = isDecisionCircuitOpen(rootDir, { config, now }) === true;
|
|
1780
|
+
} catch {
|
|
1781
|
+
circuitOpen = false;
|
|
1782
|
+
}
|
|
1783
|
+
if (circuitOpen) {
|
|
1784
|
+
result = { status: 'unavailable', fallbackCode: 'circuit-open' };
|
|
1785
|
+
} else try {
|
|
1775
1786
|
const spawned = spawnImpl(
|
|
1776
1787
|
process.execPath,
|
|
1777
1788
|
[cliPath, '--root', rootDir],
|
|
@@ -716,6 +716,99 @@ function outcome(status, extra = {}) {
|
|
|
716
716
|
return { status, finishReason: null, answers: [], ...extra };
|
|
717
717
|
}
|
|
718
718
|
|
|
719
|
+
// --- Persisted circuit breaker (SPEC FR-004 / BUG-4) -----------------------
|
|
720
|
+
// The hook path spawns a fresh process per prompt, so an in-memory breaker is
|
|
721
|
+
// useless. State lives in <projectRoot>/.ukit/storage/cache/decision-circuit.json.
|
|
722
|
+
// Advisory + best-effort: every read/parse/write error degrades to "closed".
|
|
723
|
+
const CIRCUIT_DEFAULTS = { failureThreshold: 3, cooldownMs: 30000 };
|
|
724
|
+
|
|
725
|
+
function circuitFile(projectRoot) {
|
|
726
|
+
return path.join(projectRoot, '.ukit', 'storage', 'cache', 'decision-circuit.json');
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function circuitConfig(config) {
|
|
730
|
+
const raw = decisionPlaneConfig(config).circuitBreaker;
|
|
731
|
+
const pick = (value, fallback) =>
|
|
732
|
+
Number.isFinite(value) && value >= 1 ? value : fallback;
|
|
733
|
+
return {
|
|
734
|
+
failureThreshold: pick(raw?.failureThreshold, CIRCUIT_DEFAULTS.failureThreshold),
|
|
735
|
+
cooldownMs: pick(raw?.cooldownMs, CIRCUIT_DEFAULTS.cooldownMs),
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function writeCircuitState(projectRoot, next, now) {
|
|
740
|
+
const file = circuitFile(projectRoot);
|
|
741
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
742
|
+
try {
|
|
743
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
744
|
+
fs.writeFileSync(
|
|
745
|
+
tmp,
|
|
746
|
+
JSON.stringify({
|
|
747
|
+
schemaVersion: 1,
|
|
748
|
+
consecutiveFailures: next.consecutiveFailures,
|
|
749
|
+
openedAt: next.openedAt,
|
|
750
|
+
lastFallbackCode: next.lastFallbackCode,
|
|
751
|
+
updatedAt: now,
|
|
752
|
+
}),
|
|
753
|
+
'utf8',
|
|
754
|
+
);
|
|
755
|
+
fs.renameSync(tmp, file);
|
|
756
|
+
} catch {
|
|
757
|
+
try { fs.rmSync(tmp, { force: true }); } catch { /* best-effort */ }
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
export function readDecisionCircuit(projectRoot, { config = null, now = () => Date.now() } = {}) {
|
|
762
|
+
const closed = { state: 'closed', consecutiveFailures: 0, openedAt: null, lastFallbackCode: null };
|
|
763
|
+
if (typeof projectRoot !== 'string' || projectRoot === '') return closed;
|
|
764
|
+
try {
|
|
765
|
+
const raw = JSON.parse(fs.readFileSync(circuitFile(projectRoot), 'utf8'));
|
|
766
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return closed;
|
|
767
|
+
const consecutiveFailures =
|
|
768
|
+
Number.isInteger(raw.consecutiveFailures) && raw.consecutiveFailures >= 0
|
|
769
|
+
? raw.consecutiveFailures
|
|
770
|
+
: 0;
|
|
771
|
+
const lastFallbackCode =
|
|
772
|
+
typeof raw.lastFallbackCode === 'string' ? raw.lastFallbackCode : null;
|
|
773
|
+
const base = { consecutiveFailures, openedAt: null, lastFallbackCode };
|
|
774
|
+
if (!Number.isFinite(raw.openedAt)) return { state: 'closed', ...base };
|
|
775
|
+
// Negative delta = clock moved backwards / future stamp: never pin open.
|
|
776
|
+
const delta = now() - raw.openedAt;
|
|
777
|
+
if (!(delta >= 0)) return { state: 'closed', ...base };
|
|
778
|
+
const state = delta < circuitConfig(config).cooldownMs ? 'open' : 'half-open';
|
|
779
|
+
return { state, ...base, openedAt: raw.openedAt };
|
|
780
|
+
} catch {
|
|
781
|
+
return closed;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
export function isDecisionCircuitOpen(projectRoot, opts = {}) {
|
|
786
|
+
return readDecisionCircuit(projectRoot, opts).state === 'open';
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function recordCircuitOutcome(projectRoot, prior, kind, { config, now, fallbackCode = null }) {
|
|
790
|
+
if (typeof projectRoot !== 'string' || projectRoot === '') return;
|
|
791
|
+
try {
|
|
792
|
+
const at = now();
|
|
793
|
+
if (kind === 'success') {
|
|
794
|
+
if (prior.consecutiveFailures === 0 && prior.openedAt === null) return;
|
|
795
|
+
writeCircuitState(projectRoot, {
|
|
796
|
+
consecutiveFailures: 0, openedAt: null, lastFallbackCode: null,
|
|
797
|
+
}, at);
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
const consecutiveFailures = prior.consecutiveFailures + 1;
|
|
801
|
+
const reopen =
|
|
802
|
+
prior.state === 'half-open' ||
|
|
803
|
+
consecutiveFailures >= circuitConfig(config).failureThreshold;
|
|
804
|
+
writeCircuitState(projectRoot, {
|
|
805
|
+
consecutiveFailures,
|
|
806
|
+
openedAt: reopen ? at : null,
|
|
807
|
+
lastFallbackCode: fallbackCode,
|
|
808
|
+
}, at);
|
|
809
|
+
} catch { /* advisory */ }
|
|
810
|
+
}
|
|
811
|
+
|
|
719
812
|
/**
|
|
720
813
|
* Send one C13 batch over the configured gateway. `batch` =
|
|
721
814
|
* {batchId?, boundary?, statePacket, questions, deadlineMs?}. Returns a typed
|
|
@@ -798,6 +891,24 @@ export async function requestBatch(batch, {
|
|
|
798
891
|
detail: error?.message ?? String(error),
|
|
799
892
|
});
|
|
800
893
|
}
|
|
894
|
+
// Persisted breaker (FR-004): consulted only after all local validation, so
|
|
895
|
+
// those outcomes keep their order and never touch the state file.
|
|
896
|
+
const breakerOn = typeof projectRoot === 'string' && projectRoot !== '';
|
|
897
|
+
const circuit = breakerOn
|
|
898
|
+
? readDecisionCircuit(projectRoot, { config, now })
|
|
899
|
+
: null;
|
|
900
|
+
if (circuit?.state === 'open') {
|
|
901
|
+
return outcome('unavailable', {
|
|
902
|
+
batchId: batch?.batchId ?? null,
|
|
903
|
+
checkpoint,
|
|
904
|
+
fallbackCode: 'circuit-open',
|
|
905
|
+
circuitCause: circuit.lastFallbackCode,
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
if (circuit?.state === 'half-open') {
|
|
909
|
+
// Claim the probe: concurrent processes now see 'open' until the cooldown.
|
|
910
|
+
writeCircuitState(projectRoot, { ...circuit, openedAt: now() }, now());
|
|
911
|
+
}
|
|
801
912
|
let fingerprint = requestFingerprint(body);
|
|
802
913
|
const timeoutMs = Number.isFinite(dp.timeoutMs) ? dp.timeoutMs : DEFAULT_TIMEOUT_MS;
|
|
803
914
|
const deadlineMs = Number.isFinite(batch?.deadlineMs) ? batch.deadlineMs : timeoutMs;
|
|
@@ -889,6 +1000,7 @@ export async function requestBatch(batch, {
|
|
|
889
1000
|
}
|
|
890
1001
|
|
|
891
1002
|
const result = parseBatchResponse(parsed, questions);
|
|
1003
|
+
if (breakerOn) recordCircuitOutcome(projectRoot, circuit, 'success', { config, now });
|
|
892
1004
|
return {
|
|
893
1005
|
batchId: batch?.batchId ?? null,
|
|
894
1006
|
checkpoint,
|
|
@@ -900,11 +1012,18 @@ export async function requestBatch(batch, {
|
|
|
900
1012
|
};
|
|
901
1013
|
}
|
|
902
1014
|
|
|
903
|
-
|
|
1015
|
+
const finalStatus = lastError ?? 'unavailable';
|
|
1016
|
+
const finalCode =
|
|
1017
|
+
lastStatus ?? (lastError === 'timeout' ? 'deadline-exceeded' : 'transport-error');
|
|
1018
|
+
if (breakerOn && (finalStatus === 'timeout' || finalStatus === 'unavailable')) {
|
|
1019
|
+
recordCircuitOutcome(projectRoot, circuit, 'failure', {
|
|
1020
|
+
config, now, fallbackCode: finalCode,
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
return outcome(finalStatus, {
|
|
904
1024
|
batchId: batch?.batchId ?? null,
|
|
905
1025
|
checkpoint,
|
|
906
|
-
fallbackCode:
|
|
907
|
-
lastStatus ?? (lastError === 'timeout' ? 'deadline-exceeded' : 'transport-error'),
|
|
1026
|
+
fallbackCode: finalCode,
|
|
908
1027
|
requestFingerprint: fingerprint,
|
|
909
1028
|
});
|
|
910
1029
|
}
|
|
@@ -557,12 +557,83 @@ const BOUNDARY_TAIL_BYTES = 4 * 1024 * 1024;
|
|
|
557
557
|
// 4s hook budget on a slow volume.
|
|
558
558
|
const TRANSCRIPT_BOUNDARY_PROBE_TTL_MS = 2_000;
|
|
559
559
|
|
|
560
|
+
// Persist a probe update only after this many unpersisted bytes were scanned: transcripts grow
|
|
561
|
+
// on every tool call, but rewriting compact-pressure.json (70-110KB) each time is the cost
|
|
562
|
+
// the incremental probe exists to avoid.
|
|
563
|
+
const PROBE_PERSIST_DELTA_BYTES = 256 * 1024;
|
|
564
|
+
|
|
560
565
|
function normalizeTranscriptProbe(value) {
|
|
561
566
|
if (!value || typeof value !== 'object' || typeof value.path !== 'string' || !value.path) return null;
|
|
562
567
|
const checkedAt = finiteNumber(value.checkedAt, 0);
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
568
|
+
if (!(checkedAt > 0)) return null;
|
|
569
|
+
// Only a non-negative safe integer is a usable byte offset; anything else (fractional, NaN,
|
|
570
|
+
// negative, string) is treated as 'no scannedSize' so the next probe does one full scan.
|
|
571
|
+
const scannedSize = Number.isSafeInteger(value.scannedSize) && value.scannedSize >= 0 ? value.scannedSize : null;
|
|
572
|
+
return { path: value.path, checkedAt, boundaryAt: finiteNumber(value.boundaryAt, 0), scannedSize };
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function boundaryAtOfLine(line) {
|
|
576
|
+
if (!line.includes('compact_boundary')) return 0;
|
|
577
|
+
try {
|
|
578
|
+
const entry = JSON.parse(line);
|
|
579
|
+
if (entry?.type === 'system' && entry?.subtype === 'compact_boundary') {
|
|
580
|
+
const at = Date.parse(entry.timestamp ?? '');
|
|
581
|
+
if (Number.isFinite(at)) return at;
|
|
582
|
+
}
|
|
583
|
+
} catch { /* not a usable boundary line */ }
|
|
584
|
+
return 0;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Scan `[max(fromOffset, size - tailBytes), size)` of the transcript for the newest
|
|
588
|
+
// compact_boundary (epoch ms, 0 if none). A leading partial line is dropped only when the
|
|
589
|
+
// read starts mid-line. `scannedSize` is the offset just past the last `\n` read, so a
|
|
590
|
+
// trailing line still being written is rescanned once it is complete and a delta scan always
|
|
591
|
+
// starts at a line boundary. A fromOffset beyond EOF (truncation/rotation) scans the tail.
|
|
592
|
+
// Throws on stat/open/read failure; callers decide the fallback.
|
|
593
|
+
export async function scanCompactBoundary(transcriptPath, { tailBytes = BOUNDARY_TAIL_BYTES, fromOffset = 0 } = {}) {
|
|
594
|
+
const stat = await fs.stat(transcriptPath);
|
|
595
|
+
const size = stat.size;
|
|
596
|
+
if (!stat.isFile() || size === 0) {
|
|
597
|
+
return { boundaryAt: 0, scannedSize: 0, size };
|
|
598
|
+
}
|
|
599
|
+
const requested = Number.isSafeInteger(fromOffset) && fromOffset >= 0 && fromOffset <= size ? fromOffset : 0;
|
|
600
|
+
const start = Math.max(requested, size - tailBytes);
|
|
601
|
+
// One extra leading byte tells whether `start` sits on a line boundary.
|
|
602
|
+
const readStart = start > 0 ? start - 1 : 0;
|
|
603
|
+
const length = size - readStart;
|
|
604
|
+
const buffer = Buffer.allocUnsafe(length);
|
|
605
|
+
const handle = await fs.open(transcriptPath, 'r');
|
|
606
|
+
let bytesRead;
|
|
607
|
+
try {
|
|
608
|
+
({ bytesRead } = await handle.read(buffer, 0, length, readStart));
|
|
609
|
+
} finally {
|
|
610
|
+
await handle.close();
|
|
611
|
+
}
|
|
612
|
+
const data = buffer.subarray(0, bytesRead);
|
|
613
|
+
// A delta scan must start on a line boundary (scannedSize sits just past a `\n`). If the
|
|
614
|
+
// byte before it is not `\n`, the file was replaced at the same path: rescan from the top.
|
|
615
|
+
if (requested > 0 && start === requested && data.length > 0 && data[0] !== 0x0a) {
|
|
616
|
+
return scanCompactBoundary(transcriptPath, { tailBytes, fromOffset: 0 });
|
|
617
|
+
}
|
|
618
|
+
let begin = 0;
|
|
619
|
+
let leadingPartialOnly = false;
|
|
620
|
+
if (start > 0 && data.length > 0 && data[0] !== 0x0a) {
|
|
621
|
+
const nl = data.indexOf(0x0a, 1);
|
|
622
|
+
if (nl === -1) { begin = data.length; leadingPartialOnly = true; } else { begin = nl + 1; }
|
|
623
|
+
} else if (start > 0) {
|
|
624
|
+
begin = 1;
|
|
625
|
+
}
|
|
626
|
+
const region = data.subarray(begin);
|
|
627
|
+
const lastNl = region.lastIndexOf(0x0a);
|
|
628
|
+
const scannedSize = lastNl >= 0
|
|
629
|
+
? readStart + begin + lastNl + 1
|
|
630
|
+
: (leadingPartialOnly ? start : readStart + begin);
|
|
631
|
+
const lines = region.toString('utf8').split('\n');
|
|
632
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
633
|
+
const at = boundaryAtOfLine(lines[i]);
|
|
634
|
+
if (at) return { boundaryAt: at, scannedSize, size };
|
|
635
|
+
}
|
|
636
|
+
return { boundaryAt: 0, scannedSize, size };
|
|
566
637
|
}
|
|
567
638
|
|
|
568
639
|
// Find the newest compaction recorded in the session transcript, as epoch ms (0 if none).
|
|
@@ -576,35 +647,7 @@ export async function detectLatestCompactBoundaryAt(transcriptPath, { tailBytes
|
|
|
576
647
|
return 0;
|
|
577
648
|
}
|
|
578
649
|
try {
|
|
579
|
-
|
|
580
|
-
if (!stat.isFile() || stat.size === 0) {
|
|
581
|
-
return 0;
|
|
582
|
-
}
|
|
583
|
-
const start = Math.max(0, stat.size - tailBytes);
|
|
584
|
-
const length = stat.size - start;
|
|
585
|
-
const buffer = Buffer.allocUnsafe(length);
|
|
586
|
-
const handle = await fs.open(transcriptPath, 'r');
|
|
587
|
-
let text;
|
|
588
|
-
try {
|
|
589
|
-
const { bytesRead } = await handle.read(buffer, 0, length, start);
|
|
590
|
-
text = buffer.toString('utf8', 0, bytesRead);
|
|
591
|
-
} finally {
|
|
592
|
-
await handle.close();
|
|
593
|
-
}
|
|
594
|
-
const lines = text.split('\n');
|
|
595
|
-
if (start > 0) lines.shift(); // a tail read can split the first line
|
|
596
|
-
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
597
|
-
const line = lines[i];
|
|
598
|
-
if (!line.includes('compact_boundary')) continue;
|
|
599
|
-
try {
|
|
600
|
-
const entry = JSON.parse(line);
|
|
601
|
-
if (entry?.type === 'system' && entry?.subtype === 'compact_boundary') {
|
|
602
|
-
const at = Date.parse(entry.timestamp ?? '');
|
|
603
|
-
if (Number.isFinite(at)) return at;
|
|
604
|
-
}
|
|
605
|
-
} catch { /* not a usable boundary line */ }
|
|
606
|
-
}
|
|
607
|
-
return 0;
|
|
650
|
+
return (await scanCompactBoundary(transcriptPath, { tailBytes, fromOffset: 0 })).boundaryAt;
|
|
608
651
|
} catch {
|
|
609
652
|
return 0;
|
|
610
653
|
}
|
|
@@ -616,28 +659,89 @@ export async function detectLatestCompactBoundaryAt(transcriptPath, { tailBytes
|
|
|
616
659
|
// history that was already summarised away and the hard-cap gate bricks a healthy
|
|
617
660
|
// session. Callers must persist when reset=true, or the reset would re-fire (and
|
|
618
661
|
// zero the counter) on every call.
|
|
662
|
+
//
|
|
663
|
+
// The probe is incremental: it remembers how many bytes were already scanned
|
|
664
|
+
// (`scannedSize`), so an unchanged transcript costs one stat and a grown one only reads the
|
|
665
|
+
// delta. `probed: true` tells the caller to persist; it is raised only on a meaningful change
|
|
666
|
+
// (new path, new boundary, full rescan, or >= persistDeltaBytes unpersisted bytes scanned).
|
|
619
667
|
export async function syncCompactPressureStateWithTranscript(
|
|
620
668
|
rawState = null,
|
|
621
669
|
config = {},
|
|
622
670
|
transcriptPath = '',
|
|
623
|
-
{ now = Date.now(), probeTtlMs = TRANSCRIPT_BOUNDARY_PROBE_TTL_MS } = {},
|
|
671
|
+
{ now = Date.now(), probeTtlMs = TRANSCRIPT_BOUNDARY_PROBE_TTL_MS, persistDeltaBytes = PROBE_PERSIST_DELTA_BYTES } = {},
|
|
624
672
|
) {
|
|
625
673
|
const normalizedPath = typeof transcriptPath === 'string' ? transcriptPath.trim() : '';
|
|
626
674
|
const previousProbe = normalizeTranscriptProbe(rawState?.transcriptBoundaryProbe);
|
|
627
|
-
|
|
675
|
+
const samePath = Boolean(previousProbe) && previousProbe.path === normalizedPath;
|
|
676
|
+
// A process-persistent, session-scoped cache avoids rereading/parsing the transcript for every
|
|
628
677
|
// mutation during a tool burst. It keys on the path (not just time) so a resumed/new
|
|
629
678
|
// transcript always scans immediately. The short TTL is deliberately only a performance
|
|
630
679
|
// coalescing window; PreCompact remains the immediate source of truth.
|
|
631
|
-
const probeFresh =
|
|
632
|
-
&& previousProbe.path === normalizedPath
|
|
680
|
+
const probeFresh = samePath
|
|
633
681
|
&& now - previousProbe.checkedAt >= 0
|
|
634
682
|
&& now - previousProbe.checkedAt < probeTtlMs;
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
683
|
+
|
|
684
|
+
let boundaryAt;
|
|
685
|
+
let probe;
|
|
686
|
+
let probed;
|
|
687
|
+
if (probeFresh) {
|
|
688
|
+
boundaryAt = previousProbe.boundaryAt;
|
|
689
|
+
probe = previousProbe;
|
|
690
|
+
probed = false;
|
|
691
|
+
} else {
|
|
692
|
+
// `persistedScannedSize` is the scannedSize loaded from disk via rawState: the gate only
|
|
693
|
+
// persists when probed is true, so this bounds the delta a later process must re-read.
|
|
694
|
+
const persistedScannedSize = samePath ? previousProbe.scannedSize : null;
|
|
695
|
+
const fullScan = async () => {
|
|
696
|
+
const scan = await scanCompactBoundary(normalizedPath, { fromOffset: 0 });
|
|
697
|
+
return {
|
|
698
|
+
boundaryAt: scan.boundaryAt,
|
|
699
|
+
probe: { path: normalizedPath, checkedAt: now, boundaryAt: scan.boundaryAt, scannedSize: scan.scannedSize },
|
|
700
|
+
probed: true,
|
|
701
|
+
};
|
|
702
|
+
};
|
|
703
|
+
const incrementalScan = async () => {
|
|
704
|
+
const { size } = await fs.stat(normalizedPath);
|
|
705
|
+
if (size === persistedScannedSize) {
|
|
706
|
+
// Nothing new since the persisted probe: reuse, no read.
|
|
707
|
+
const reused = previousProbe.boundaryAt;
|
|
708
|
+
return {
|
|
709
|
+
boundaryAt: reused,
|
|
710
|
+
probe: { path: normalizedPath, checkedAt: now, boundaryAt: reused, scannedSize: persistedScannedSize },
|
|
711
|
+
probed: false,
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
if (size < persistedScannedSize) return fullScan(); // truncation/rotation
|
|
715
|
+
const scan = await scanCompactBoundary(normalizedPath, { fromOffset: persistedScannedSize });
|
|
716
|
+
const merged = scan.boundaryAt || previousProbe.boundaryAt;
|
|
717
|
+
return {
|
|
718
|
+
boundaryAt: merged,
|
|
719
|
+
probe: { path: normalizedPath, checkedAt: now, boundaryAt: merged, scannedSize: scan.scannedSize },
|
|
720
|
+
probed: merged !== previousProbe.boundaryAt
|
|
721
|
+
|| scan.scannedSize - persistedScannedSize >= persistDeltaBytes,
|
|
722
|
+
};
|
|
723
|
+
};
|
|
724
|
+
let result = null;
|
|
725
|
+
if (persistedScannedSize !== null) {
|
|
726
|
+
// Any incremental failure retries one full scan, so the gate decides exactly as a full
|
|
727
|
+
// re-read would; only if that fails too do we fall back to the previous boundary.
|
|
728
|
+
try { result = await incrementalScan(); } catch { result = null; }
|
|
729
|
+
}
|
|
730
|
+
if (!result) {
|
|
731
|
+
try {
|
|
732
|
+
result = await fullScan();
|
|
733
|
+
} catch {
|
|
734
|
+
// stat/open/read failure: keep the previous boundary for the same path, else 0.
|
|
735
|
+
const kept = samePath ? previousProbe.boundaryAt : 0;
|
|
736
|
+
result = {
|
|
737
|
+
boundaryAt: kept,
|
|
738
|
+
probe: { path: normalizedPath, checkedAt: now, boundaryAt: kept, scannedSize: persistedScannedSize },
|
|
739
|
+
probed: !samePath,
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
({ boundaryAt, probe, probed } = result);
|
|
744
|
+
}
|
|
641
745
|
const accounted = finiteNumber(rawState?.lastCompactBoundaryAt, 0);
|
|
642
746
|
if (!boundaryAt || boundaryAt <= accounted) {
|
|
643
747
|
return {
|
|
@@ -645,7 +749,7 @@ export async function syncCompactPressureStateWithTranscript(
|
|
|
645
749
|
? { ...rawState, transcriptBoundaryProbe: probe }
|
|
646
750
|
: rawState,
|
|
647
751
|
reset: false,
|
|
648
|
-
probed
|
|
752
|
+
probed,
|
|
649
753
|
};
|
|
650
754
|
}
|
|
651
755
|
return {
|
|
@@ -655,7 +759,7 @@ export async function syncCompactPressureStateWithTranscript(
|
|
|
655
759
|
transcriptBoundaryProbe: probe,
|
|
656
760
|
},
|
|
657
761
|
reset: true,
|
|
658
|
-
probed
|
|
762
|
+
probed,
|
|
659
763
|
};
|
|
660
764
|
}
|
|
661
765
|
|
|
@@ -160,6 +160,7 @@ export async function sweepExecLedgerDir(dir, {
|
|
|
160
160
|
maxFiles = LEDGER_MAX_FILES,
|
|
161
161
|
maxEntries = LEDGER_SWEEP_MAX_ENTRIES,
|
|
162
162
|
maxRemovals = LEDGER_SWEEP_MAX_REMOVALS,
|
|
163
|
+
random = Math.random,
|
|
163
164
|
} = {}) {
|
|
164
165
|
let dirents;
|
|
165
166
|
try {
|
|
@@ -178,9 +179,15 @@ export async function sweepExecLedgerDir(dir, {
|
|
|
178
179
|
if (dirent.isFile()) eligibleNames.push(dirent.name);
|
|
179
180
|
}
|
|
180
181
|
const entries = [];
|
|
182
|
+
// FR-002 (BUG-2): scan a circular window of at most maxEntries names from a
|
|
183
|
+
// random start so files past the first maxEntries are eventually pruned.
|
|
184
|
+
const total = eligibleNames.length;
|
|
185
|
+
const rawStart = total > maxEntries ? Math.floor(random() * total) : 0;
|
|
186
|
+
const start = Number.isFinite(rawStart) ? Math.min(total - 1, Math.max(0, rawStart)) : 0;
|
|
187
|
+
const limit = Math.min(maxEntries, total);
|
|
181
188
|
let scanned = 0;
|
|
182
|
-
for (
|
|
183
|
-
|
|
189
|
+
for (let i = 0; i < limit; i += 1) {
|
|
190
|
+
const name = eligibleNames[(start + i) % total];
|
|
184
191
|
scanned += 1;
|
|
185
192
|
try {
|
|
186
193
|
const stats = await fs.stat(path.join(dir, name));
|
|
@@ -258,7 +265,7 @@ export async function maybeSweepExecLedgerDir(dir, {
|
|
|
258
265
|
? Math.min(1, Math.max(0, probability))
|
|
259
266
|
: ledgerSweepProbabilityFromEnv();
|
|
260
267
|
if (random() >= p) return { sampled: false, scanned: 0, removed: 0 };
|
|
261
|
-
return { sampled: true, ...(await sweepExecLedgerDir(dir, options)) };
|
|
268
|
+
return { sampled: true, ...(await sweepExecLedgerDir(dir, { ...options, random })) };
|
|
262
269
|
}
|
|
263
270
|
|
|
264
271
|
function resumeIntentPath(projectRoot, sessionId) {
|
|
@@ -296,6 +296,13 @@ async function run(payloadText, scriptArgs, { chainMarker = true, decisionShortC
|
|
|
296
296
|
// (ask + exit 2). Under --emit-verdict the runner replays the direct
|
|
297
297
|
// Claude contract (deny + exit 0), so children must NOT see it.
|
|
298
298
|
...(chainMarker ? { UKIT_HOOK_CHAIN_RUNNER: '1' } : {}),
|
|
299
|
+
// BUG-3 (TASK-003): the runner owns hook-latency rows for every chained
|
|
300
|
+
// step (see recordHookTiming below). This marker keeps hook-telemetry.sh
|
|
301
|
+
// unarmed in `.sh` children so they spawn no `--finish` node/sleep.
|
|
302
|
+
// Distinct from UKIT_HOOK_CHAIN_RUNNER, which is withheld under
|
|
303
|
+
// --emit-verdict; this one is set in both modes. Direct invocations
|
|
304
|
+
// (no runner) never see it and stay armed.
|
|
305
|
+
UKIT_HOOK_TELEMETRY_BY_RUNNER: '1',
|
|
299
306
|
};
|
|
300
307
|
const stepDeadlineMs = Math.min(childBudgetsMs[scriptIndex], remainingMs);
|
|
301
308
|
// TASK-001: `.mjs` args run in-proc via runHook(ctx); `.sh` stays a child
|
|
@@ -350,21 +357,21 @@ async function run(payloadText, scriptArgs, { chainMarker = true, decisionShortC
|
|
|
350
357
|
elapsedMs: Date.now() - childStartedAt,
|
|
351
358
|
});
|
|
352
359
|
|
|
353
|
-
// TASK-001 (FR-003)
|
|
354
|
-
//
|
|
355
|
-
//
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
}
|
|
360
|
+
// TASK-001 (FR-003) / TASK-003 (BUG-3): the runner is the single telemetry
|
|
361
|
+
// owner for every chained step. `.mjs` steps run in-proc and cannot write
|
|
362
|
+
// their own row; `.sh` children are told via UKIT_HOOK_TELEMETRY_BY_RUNNER
|
|
363
|
+
// not to spawn hook-telemetry.mjs --finish. One row per step, hook =
|
|
364
|
+
// basename incl. extension; elapsedMs includes bash start-up.
|
|
365
|
+
recordHookTiming({
|
|
366
|
+
projectRoot,
|
|
367
|
+
sessionId: payload?.session_id,
|
|
368
|
+
event: payload?.hook_event_name || null,
|
|
369
|
+
tool: payload?.tool_name || null,
|
|
370
|
+
toolUseId: payload?.tool_use_id || null,
|
|
371
|
+
hook: scriptName,
|
|
372
|
+
elapsedMs: Date.now() - childStartedAt,
|
|
373
|
+
outcome: failureKind,
|
|
374
|
+
});
|
|
368
375
|
|
|
369
376
|
// B1 (TASK-001, SPEC §FR-001): under `--emit-verdict` a child that emits a
|
|
370
377
|
// `hookSpecificOutput` permission decision owns the verdict even at exit 0
|
|
@@ -195,6 +195,7 @@ export async function sweepStalePayloadsAsync(dir, {
|
|
|
195
195
|
now = Date.now,
|
|
196
196
|
maxAgeMs = SWEEP_MAX_AGE_MS,
|
|
197
197
|
maxEntries = SWEEP_MAX_ENTRIES,
|
|
198
|
+
random = Math.random,
|
|
198
199
|
} = {}) {
|
|
199
200
|
let names;
|
|
200
201
|
try {
|
|
@@ -203,12 +204,18 @@ export async function sweepStalePayloadsAsync(dir, {
|
|
|
203
204
|
return { sampled: true, scanned: 0, removed: 0 };
|
|
204
205
|
}
|
|
205
206
|
const cutoff = now() - maxAgeMs;
|
|
207
|
+
const eligibleNames = names.filter((name) => name.endsWith('.json') || name.endsWith('.tmp'));
|
|
208
|
+
// FR-002 (BUG-2): scan a circular window of at most maxEntries names from a
|
|
209
|
+
// random start so files past the first maxEntries are eventually pruned.
|
|
210
|
+
const total = eligibleNames.length;
|
|
211
|
+
const rawStart = total > maxEntries ? Math.floor(random() * total) : 0;
|
|
212
|
+
const start = Number.isFinite(rawStart) ? Math.min(total - 1, Math.max(0, rawStart)) : 0;
|
|
213
|
+
const limit = Math.min(maxEntries, total);
|
|
206
214
|
let scanned = 0;
|
|
207
215
|
let removed = 0;
|
|
208
|
-
for (
|
|
209
|
-
|
|
216
|
+
for (let i = 0; i < limit; i += 1) {
|
|
217
|
+
const name = eligibleNames[(start + i) % total];
|
|
210
218
|
scanned += 1;
|
|
211
|
-
if (!name.endsWith('.json') && !name.endsWith('.tmp')) continue;
|
|
212
219
|
const filePath = path.join(dir, name);
|
|
213
220
|
try {
|
|
214
221
|
if ((await fsp.stat(filePath)).mtimeMs < cutoff) {
|
|
@@ -234,7 +241,7 @@ export async function maybeSweepStalePayloadsAsync(dir, {
|
|
|
234
241
|
} = {}) {
|
|
235
242
|
const p = Number.isFinite(probability) ? Math.min(1, Math.max(0, probability)) : sweepProbabilityFromEnv();
|
|
236
243
|
if (random() >= p) return { sampled: false, scanned: 0, removed: 0 };
|
|
237
|
-
return sweepStalePayloadsAsync(dir, { now, maxAgeMs, maxEntries });
|
|
244
|
+
return sweepStalePayloadsAsync(dir, { now, maxAgeMs, maxEntries, random });
|
|
238
245
|
}
|
|
239
246
|
|
|
240
247
|
// sweepStalePayloads(dir, {now, maxAgeMs, maxEntries}) -> {sampled, scanned, removed}
|
|
@@ -247,6 +254,7 @@ export function sweepStalePayloads(dir, {
|
|
|
247
254
|
now = Date.now,
|
|
248
255
|
maxAgeMs = SWEEP_MAX_AGE_MS,
|
|
249
256
|
maxEntries = SWEEP_MAX_ENTRIES,
|
|
257
|
+
random = Math.random,
|
|
250
258
|
} = {}) {
|
|
251
259
|
let names;
|
|
252
260
|
try {
|
|
@@ -255,12 +263,18 @@ export function sweepStalePayloads(dir, {
|
|
|
255
263
|
return { sampled: true, scanned: 0, removed: 0 };
|
|
256
264
|
}
|
|
257
265
|
const cutoff = now() - maxAgeMs;
|
|
266
|
+
const eligibleNames = names.filter((name) => name.endsWith('.json') || name.endsWith('.tmp'));
|
|
267
|
+
// FR-002 (BUG-2): scan a circular window of at most maxEntries names from a
|
|
268
|
+
// random start so files past the first maxEntries are eventually pruned.
|
|
269
|
+
const total = eligibleNames.length;
|
|
270
|
+
const rawStart = total > maxEntries ? Math.floor(random() * total) : 0;
|
|
271
|
+
const start = Number.isFinite(rawStart) ? Math.min(total - 1, Math.max(0, rawStart)) : 0;
|
|
272
|
+
const limit = Math.min(maxEntries, total);
|
|
258
273
|
let scanned = 0;
|
|
259
274
|
let removed = 0;
|
|
260
|
-
for (
|
|
261
|
-
|
|
275
|
+
for (let i = 0; i < limit; i += 1) {
|
|
276
|
+
const name = eligibleNames[(start + i) % total];
|
|
262
277
|
scanned += 1;
|
|
263
|
-
if (!name.endsWith('.json') && !name.endsWith('.tmp')) continue;
|
|
264
278
|
const filePath = path.join(dir, name);
|
|
265
279
|
try {
|
|
266
280
|
if (fs.statSync(filePath).mtimeMs < cutoff) {
|
|
@@ -287,5 +301,5 @@ export function maybeSweepStalePayloads(dir, {
|
|
|
287
301
|
} = {}) {
|
|
288
302
|
const p = Number.isFinite(probability) ? Math.min(1, Math.max(0, probability)) : sweepProbabilityFromEnv();
|
|
289
303
|
if (random() >= p) return { sampled: false, scanned: 0, removed: 0 };
|
|
290
|
-
return sweepStalePayloads(dir, { now, maxAgeMs, maxEntries });
|
|
304
|
+
return sweepStalePayloads(dir, { now, maxAgeMs, maxEntries, random });
|
|
291
305
|
}
|
|
@@ -231,6 +231,7 @@ export function sweepTelemetryDir(dir, {
|
|
|
231
231
|
maxFiles = TELEMETRY_MAX_FILES,
|
|
232
232
|
maxEntries = TELEMETRY_SWEEP_MAX_ENTRIES,
|
|
233
233
|
maxRemovals = TELEMETRY_SWEEP_MAX_REMOVALS,
|
|
234
|
+
random = Math.random,
|
|
234
235
|
} = {}) {
|
|
235
236
|
let names;
|
|
236
237
|
try {
|
|
@@ -245,9 +246,15 @@ export function sweepTelemetryDir(dir, {
|
|
|
245
246
|
// `entries.length` an under-count and `overflow` was always 0.
|
|
246
247
|
const eligibleNames = names.filter((name) => name.endsWith('.jsonl'));
|
|
247
248
|
const entries = [];
|
|
249
|
+
// FR-002 (BUG-2): scan a circular window of at most maxEntries names from a
|
|
250
|
+
// random start so files past the first maxEntries are eventually pruned.
|
|
251
|
+
const total = eligibleNames.length;
|
|
252
|
+
const rawStart = total > maxEntries ? Math.floor(random() * total) : 0;
|
|
253
|
+
const start = Number.isFinite(rawStart) ? Math.min(total - 1, Math.max(0, rawStart)) : 0;
|
|
254
|
+
const limit = Math.min(maxEntries, total);
|
|
248
255
|
let scanned = 0;
|
|
249
|
-
for (
|
|
250
|
-
|
|
256
|
+
for (let i = 0; i < limit; i += 1) {
|
|
257
|
+
const name = eligibleNames[(start + i) % total];
|
|
251
258
|
scanned += 1;
|
|
252
259
|
try {
|
|
253
260
|
const stats = fs.statSync(path.join(dir, name));
|
|
@@ -283,7 +290,7 @@ export function maybeSweepTelemetryDir(dir, {
|
|
|
283
290
|
? Math.min(1, Math.max(0, probability))
|
|
284
291
|
: telemetrySweepProbabilityFromEnv();
|
|
285
292
|
if (random() >= p) return { sampled: false, scanned: 0, removed: 0 };
|
|
286
|
-
return { sampled: true, ...sweepTelemetryDir(dir, options) };
|
|
293
|
+
return { sampled: true, ...sweepTelemetryDir(dir, { ...options, random }) };
|
|
287
294
|
}
|
|
288
295
|
|
|
289
296
|
// BUG-C23-10: a bare `catch {}` around the sweep swallowed every failure — a
|
|
@@ -17,10 +17,21 @@
|
|
|
17
17
|
# without adding a node spawn to the hot path — and trap
|
|
18
18
|
# ukit_hook_telemetry_finish on EXIT themselves.
|
|
19
19
|
#
|
|
20
|
+
# BUG-3 (TASK-003): inside hook-chain-runner.mjs the runner is the single
|
|
21
|
+
# telemetry owner — it times every step and writes the hook-latency row itself.
|
|
22
|
+
# It exports UKIT_HOOK_TELEMETRY_BY_RUNNER=1 to each child; when that marker is
|
|
23
|
+
# 1 this file stays unarmed (UKIT_TEL_ARMED=0) and arm/finish return at once —
|
|
24
|
+
# no mktemp, no node, no sleep watchdog. Direct invocations (Codex direct hooks,
|
|
25
|
+
# debug runs) carry no marker and behave exactly as before.
|
|
26
|
+
#
|
|
20
27
|
# Missing runtime (pre-install tree) simply leaves telemetry off: wrappers
|
|
21
28
|
# source this file with `|| true` and the armed flag stays unset.
|
|
22
29
|
|
|
23
|
-
|
|
30
|
+
if [ "${UKIT_HOOK_TELEMETRY_BY_RUNNER:-}" = "1" ]; then
|
|
31
|
+
UKIT_TEL_ARMED=0
|
|
32
|
+
else
|
|
33
|
+
UKIT_TEL_ARMED=1
|
|
34
|
+
fi
|
|
24
35
|
UKIT_TEL_HOOK="$(basename "${BASH_SOURCE[1]:-$0}")"
|
|
25
36
|
UKIT_TEL_RUNTIME_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
26
37
|
UKIT_TEL_START_FILE=""
|
|
@@ -52,6 +63,7 @@ ukit_tel_file_ms() {
|
|
|
52
63
|
# one mktemp; a `node -e` clock read would cost ~30ms to measure milliseconds.
|
|
53
64
|
# Always returns 0 — an unarmed hook must behave exactly like an unmeasured one.
|
|
54
65
|
ukit_hook_telemetry_arm() {
|
|
66
|
+
[ "${UKIT_TEL_ARMED:-}" = "1" ] || return 0
|
|
55
67
|
UKIT_TEL_START_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-tel-start.XXXXXX" 2>/dev/null)" || {
|
|
56
68
|
UKIT_TEL_START_FILE=""
|
|
57
69
|
return 0
|
|
@@ -65,6 +77,7 @@ ukit_hook_telemetry_finish() {
|
|
|
65
77
|
# captured it before its own conditionals could reset $?); $? is only the
|
|
66
78
|
# fallback for direct debug invocation.
|
|
67
79
|
local rc="${UKIT_TEL_RC:-$?}"
|
|
80
|
+
[ "${UKIT_TEL_ARMED:-}" = "1" ] || return 0
|
|
68
81
|
command -v node >/dev/null 2>&1 || return 0
|
|
69
82
|
# UKIT_INPUT_FILE is a plain shell variable (never exported), so the staged
|
|
70
83
|
# payload path — and through it the envelope start mtime — is passed here
|
|
@@ -455,6 +455,7 @@ export async function sweepTeeCache(dir, {
|
|
|
455
455
|
maxFiles = TEE_MAX_FILES,
|
|
456
456
|
maxEntries = TEE_SWEEP_MAX_ENTRIES,
|
|
457
457
|
maxRemovals = TEE_SWEEP_MAX_REMOVALS,
|
|
458
|
+
random = Math.random,
|
|
458
459
|
} = {}) {
|
|
459
460
|
let dirents;
|
|
460
461
|
try {
|
|
@@ -478,8 +479,17 @@ export async function sweepTeeCache(dir, {
|
|
|
478
479
|
const cutoff = now() - maxAgeMs;
|
|
479
480
|
const entries = [];
|
|
480
481
|
let scanned = 0;
|
|
481
|
-
|
|
482
|
-
|
|
482
|
+
// Circular window at a random start so expired files beyond the first
|
|
483
|
+
// maxEntries readdir names are still reached (BUG-1: the head slice never
|
|
484
|
+
// age-pruned the tail). Non-finite random() falls back to start 0.
|
|
485
|
+
const L = eligibleNames.length;
|
|
486
|
+
const r = random();
|
|
487
|
+
const start = L > maxEntries
|
|
488
|
+
? Math.min(L - 1, Math.max(0, Math.floor((Number.isFinite(r) ? r : 0) * L)))
|
|
489
|
+
: 0;
|
|
490
|
+
const windowSize = Math.min(maxEntries, L);
|
|
491
|
+
for (let i = 0; i < windowSize; i += 1) {
|
|
492
|
+
const name = eligibleNames[(start + i) % L];
|
|
483
493
|
scanned += 1;
|
|
484
494
|
try {
|
|
485
495
|
const stats = await fs.stat(path.join(dir, name));
|
|
@@ -516,7 +526,7 @@ export async function maybeSweepTeeCache(dir, {
|
|
|
516
526
|
? Math.min(1, Math.max(0, probability))
|
|
517
527
|
: teeSweepProbabilityFromEnv();
|
|
518
528
|
if (random() >= p) return { sampled: false, scanned: 0, removed: 0 };
|
|
519
|
-
return sweepTeeCache(dir, options);
|
|
529
|
+
return sweepTeeCache(dir, { ...options, random });
|
|
520
530
|
}
|
|
521
531
|
|
|
522
532
|
async function persistRawOutput(projectRoot, {
|
|
@@ -454,6 +454,7 @@ async function sweepHookErrorsDir(dir, {
|
|
|
454
454
|
maxFiles = HOOK_ERROR_MAX_FILES,
|
|
455
455
|
maxEntries = HOOK_ERROR_SWEEP_MAX_ENTRIES,
|
|
456
456
|
maxRemovals = HOOK_ERROR_SWEEP_MAX_REMOVALS,
|
|
457
|
+
random = Math.random,
|
|
457
458
|
} = {}) {
|
|
458
459
|
let names;
|
|
459
460
|
try {
|
|
@@ -468,9 +469,15 @@ async function sweepHookErrorsDir(dir, {
|
|
|
468
469
|
// `entries.length` an under-count and `overflow` was always 0.
|
|
469
470
|
const eligibleNames = names.filter((name) => name.endsWith('.jsonl'));
|
|
470
471
|
const entries = [];
|
|
472
|
+
// FR-002 (BUG-2): scan a circular window of at most maxEntries names from a
|
|
473
|
+
// random start so files past the first maxEntries are eventually pruned.
|
|
474
|
+
const total = eligibleNames.length;
|
|
475
|
+
const rawStart = total > maxEntries ? Math.floor(random() * total) : 0;
|
|
476
|
+
const start = Number.isFinite(rawStart) ? Math.min(total - 1, Math.max(0, rawStart)) : 0;
|
|
477
|
+
const limit = Math.min(maxEntries, total);
|
|
471
478
|
let scanned = 0;
|
|
472
|
-
for (
|
|
473
|
-
|
|
479
|
+
for (let i = 0; i < limit; i += 1) {
|
|
480
|
+
const name = eligibleNames[(start + i) % total];
|
|
474
481
|
scanned += 1;
|
|
475
482
|
try {
|
|
476
483
|
const stats = await fs.promises.stat(path.join(dir, name));
|