@ngockhoale/ukit 3.4.20 → 3.4.22
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 +25 -0
- package/manifests/documentation.yaml +11 -0
- package/manifests/platform.full.yaml +13 -0
- package/package.json +1 -1
- package/src/core/hookChainDoctor.js +39 -3
- package/src/core/output/index.js +13 -3
- package/template_project/.claude/.gitattributes +2 -0
- 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 +105 -21
- package/template_project/.claude/ukit/runtime/hook-payload-store.mjs +22 -8
- package/template_project/.claude/ukit/runtime/hook-process.mjs +120 -4
- 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 +21 -2
- package/template_project/docs/UKIT_INTERNALS.md +14 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to UKit are documented here.
|
|
4
4
|
|
|
5
|
+
## 3.4.22 - 2026-10-07
|
|
6
|
+
|
|
7
|
+
**Windows: `.sh` hooks no longer deny every Edit/Write/Bash.**
|
|
8
|
+
- `.sh` hooks are now launched through Git for Windows bash: `UKIT_BASH_BIN` → `CLAUDE_CODE_GIT_BASH_PATH` →
|
|
9
|
+
default Git install paths → absolute `PATH` entries. WSL's `bash.exe` and a cwd `bash.exe` are never used.
|
|
10
|
+
- On Windows a `.sh` hook whose bash cannot be found or started is reported as `spawn-unavailable` and warned
|
|
11
|
+
instead of denying every Edit/Write/Bash. POSIX behaviour is unchanged; timeouts, real blocks, missing gate
|
|
12
|
+
files and `block-dangerous.sh` stay fail-closed.
|
|
13
|
+
- `ukit doctor` flags Windows hook launch failures.
|
|
14
|
+
- `*.sh` is pinned to LF (repo `.gitattributes` and a shipped `.claude/.gitattributes`) so a CRLF checkout cannot
|
|
15
|
+
break hook scripts. Troubleshooting added to `docs/INSTALL.md`.
|
|
16
|
+
|
|
17
|
+
## 3.4.21 - 2026-10-07
|
|
18
|
+
|
|
19
|
+
**Hook runtime no longer burns CPU/disk on every tool call** (five measured bugs, C95).
|
|
20
|
+
- Retention sweeps (tee cache, hook-latency telemetry, execution ledger, hook payloads, omp hook errors) only
|
|
21
|
+
looked at the first 128 directory entries, so old files past that window were never pruned and the
|
|
22
|
+
over-cap rule deleted newer files instead. The scan window now rotates.
|
|
23
|
+
- Chained `.sh` hooks no longer each spawn their own `node hook-telemetry.mjs --finish`; `hook-chain-runner.mjs`
|
|
24
|
+
writes the per-step rows (an Edit chain went from 11 node spawns to 5, same telemetry rows).
|
|
25
|
+
- `unic-decision` now honours its persisted circuit breaker (closed/open/half-open, fail-open): a rejected key
|
|
26
|
+
or dead endpoint no longer costs a failed call on every prompt.
|
|
27
|
+
- The context hard-cap gate probes the transcript incrementally instead of re-reading the last 4 MB and
|
|
28
|
+
rewriting `compact-pressure.json` on ~95% of calls; invalid persisted probe state falls back to a full scan.
|
|
29
|
+
|
|
5
30
|
## 3.4.20 - 2026-10-06
|
|
6
31
|
|
|
7
32
|
**`ukit update` now displays the NEW version.** The running process is the old code, so the
|
|
@@ -707,6 +707,17 @@ entries:
|
|
|
707
707
|
validation: [manual]
|
|
708
708
|
archive_policy: never
|
|
709
709
|
notes: non-.md entry — exists for FR-002 manifest consistency
|
|
710
|
+
- id: tpl-claude-gitattributes
|
|
711
|
+
path: template_project/.claude/.gitattributes
|
|
712
|
+
class: canonical
|
|
713
|
+
audience: [maintainer]
|
|
714
|
+
owner: product
|
|
715
|
+
source_of_truth: template_project/.claude/.gitattributes
|
|
716
|
+
merge_strategy: skip
|
|
717
|
+
load_policy: on-demand
|
|
718
|
+
validation: [manual]
|
|
719
|
+
archive_policy: never
|
|
720
|
+
notes: non-.md entry — keeps hook *.sh LF on Windows autocrlf checkouts; exists for FR-002 manifest consistency
|
|
710
721
|
|
|
711
722
|
# ── repo mirrors (derived from template_project/) ───────────────────────────────
|
|
712
723
|
- id: mirror-claude-dir
|
|
@@ -1511,6 +1511,19 @@ items:
|
|
|
1511
1511
|
packs:
|
|
1512
1512
|
- core
|
|
1513
1513
|
|
|
1514
|
+
# Ships `*.sh text eol=lf` inside .claude/ only (never the user's root .gitattributes) so
|
|
1515
|
+
# a Windows autocrlf checkout cannot turn hook scripts into CRLF that bash rejects.
|
|
1516
|
+
- id: claude-gitattributes
|
|
1517
|
+
type: config
|
|
1518
|
+
sourceTemplate: .claude/.gitattributes
|
|
1519
|
+
targetPath: .claude/.gitattributes
|
|
1520
|
+
requires: []
|
|
1521
|
+
mergeStrategy: skip
|
|
1522
|
+
variables: []
|
|
1523
|
+
enabledByDefault: true
|
|
1524
|
+
packs:
|
|
1525
|
+
- core
|
|
1526
|
+
|
|
1514
1527
|
- id: hook-reset-compact-pressure
|
|
1515
1528
|
type: hook
|
|
1516
1529
|
sourceTemplate: .claude/hooks/reset-compact-pressure.sh
|
package/package.json
CHANGED
|
@@ -24,12 +24,31 @@ const FAILURE_OUTCOMES = new Set([
|
|
|
24
24
|
'output-overflow',
|
|
25
25
|
'error',
|
|
26
26
|
'signal',
|
|
27
|
+
'spawn-unavailable',
|
|
27
28
|
]);
|
|
28
29
|
|
|
29
30
|
// The pre-O6 remedy, kept verbatim: a failure with no gate evidence must read
|
|
30
31
|
// exactly as it did before escalation existed (SPEC §FR-012).
|
|
32
|
+
// SPEC §FR-007: launch failures only arise on win32 (the runner emits
|
|
33
|
+
// `spawn-unavailable` from its bash-launcher path), so there is one remedy.
|
|
34
|
+
const LAUNCH_REMEDY = 'Windows cannot execute .sh hooks directly — install Git for Windows so C:\\Program Files\\Git\\bin\\bash.exe exists, or set UKIT_BASH_BIN to the full path of bash.exe, then restart the session.';
|
|
35
|
+
|
|
36
|
+
// Legacy pre-fix win32 rows recorded a failed .sh launch as a plain `error`
|
|
37
|
+
// that finished almost instantly.
|
|
38
|
+
const LEGACY_LAUNCH_FAILURE_MAX_MS = 10;
|
|
39
|
+
|
|
31
40
|
const BASE_REMEDY = 'Inspect recent rows in .ukit/storage/cache/hook-latency/ for the failing scriptName/failureKind and fix or widen the gate budget.';
|
|
32
41
|
|
|
42
|
+
function isLaunchFailure(entry, platform) {
|
|
43
|
+
if (!entry || typeof entry !== 'object') return false;
|
|
44
|
+
if (typeof entry.scriptName !== 'string' || !entry.scriptName.toLowerCase().endsWith('.sh')) return false;
|
|
45
|
+
if (entry.failureKind === 'spawn-unavailable') return true;
|
|
46
|
+
return platform === 'win32'
|
|
47
|
+
&& entry.failureKind === 'error'
|
|
48
|
+
&& typeof entry.elapsedMs === 'number'
|
|
49
|
+
&& entry.elapsedMs < LEGACY_LAUNCH_FAILURE_MAX_MS;
|
|
50
|
+
}
|
|
51
|
+
|
|
33
52
|
function telemetryDirFor(projectRoot) {
|
|
34
53
|
return path.join(projectRoot, '.ukit', 'storage', 'cache', 'hook-latency');
|
|
35
54
|
}
|
|
@@ -78,7 +97,7 @@ async function tailLines(filePath, size) {
|
|
|
78
97
|
}
|
|
79
98
|
}
|
|
80
99
|
|
|
81
|
-
export async function inspectHookChainHealth({ projectRoot, now = Date.now() } = {}) {
|
|
100
|
+
export async function inspectHookChainHealth({ projectRoot, now = Date.now(), platform = process.platform } = {}) {
|
|
82
101
|
const label = 'hook-chain health';
|
|
83
102
|
|
|
84
103
|
// Never-fatal posture extends to malformed input: a missing projectRoot is
|
|
@@ -106,6 +125,7 @@ export async function inspectHookChainHealth({ projectRoot, now = Date.now() } =
|
|
|
106
125
|
// evidence shapes that prove it, collected in the same pass that counts
|
|
107
126
|
// outcomes so no second read of the rows is needed.
|
|
108
127
|
const gateFailures = new Set();
|
|
128
|
+
const launchFailures = new Set();
|
|
109
129
|
let budgetExhaustedChains = 0;
|
|
110
130
|
|
|
111
131
|
for (const file of files) {
|
|
@@ -130,6 +150,14 @@ export async function inspectHookChainHealth({ projectRoot, now = Date.now() } =
|
|
|
130
150
|
const outcome = typeof row.outcome === 'string' && row.outcome.length > 0 ? row.outcome : 'ok';
|
|
131
151
|
counts[outcome] = (counts[outcome] ?? 0) + 1;
|
|
132
152
|
|
|
153
|
+
// .sh launch failures are collected from EVERY row (an exit-code row can
|
|
154
|
+
// still carry one); they are a different signal from a gate that ran and failed.
|
|
155
|
+
if (Array.isArray(row.scripts)) {
|
|
156
|
+
for (const entry of row.scripts) {
|
|
157
|
+
if (isLaunchFailure(entry, platform)) launchFailures.add(entry.scriptName);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
133
161
|
// Escalation evidence is only ever read off a FAILING row — a gate that
|
|
134
162
|
// ran fine is not evidence of anything, and SPEC §FR-012 scopes the
|
|
135
163
|
// budget signal to a non-ok row ("a non-ok row ... escalates only when
|
|
@@ -155,6 +183,7 @@ export async function inspectHookChainHealth({ projectRoot, now = Date.now() } =
|
|
|
155
183
|
if (!entry || typeof entry !== 'object') continue;
|
|
156
184
|
if (entry.failClosed !== true) continue;
|
|
157
185
|
if (typeof entry.failureKind !== 'string' || entry.failureKind === 'ok') continue;
|
|
186
|
+
if (entry.failureKind === 'spawn-unavailable') continue; // reported as a launch failure
|
|
158
187
|
gateFailures.add(typeof entry.scriptName === 'string' ? entry.scriptName : 'unknown');
|
|
159
188
|
}
|
|
160
189
|
}
|
|
@@ -177,7 +206,7 @@ export async function inspectHookChainHealth({ projectRoot, now = Date.now() } =
|
|
|
177
206
|
.filter((key) => FAILURE_OUTCOMES.has(key) && counts[key] > 0)
|
|
178
207
|
.sort();
|
|
179
208
|
|
|
180
|
-
if (failureKinds.length === 0) {
|
|
209
|
+
if (failureKinds.length === 0 && launchFailures.size === 0) {
|
|
181
210
|
return {
|
|
182
211
|
label,
|
|
183
212
|
passed: true,
|
|
@@ -190,7 +219,9 @@ export async function inspectHookChainHealth({ projectRoot, now = Date.now() } =
|
|
|
190
219
|
}
|
|
191
220
|
|
|
192
221
|
const breakdown = failureKinds.map((kind) => `${kind}=${counts[kind]}`).join(', ');
|
|
193
|
-
const baseDetail =
|
|
222
|
+
const baseDetail = failureKinds.length > 0
|
|
223
|
+
? `${counts.rowsScanned} chain row(s) scanned — fail-closed outcomes: ${breakdown}`
|
|
224
|
+
: `${counts.rowsScanned} chain row(s) scanned`;
|
|
194
225
|
|
|
195
226
|
// O6 (SPEC §FR-012): escalate only when a failure actually landed ON a gate —
|
|
196
227
|
// a gate that merely ran is not evidence. The escalation is purely additive:
|
|
@@ -207,6 +238,11 @@ export async function inspectHookChainHealth({ projectRoot, now = Date.now() } =
|
|
|
207
238
|
`A fail-closed gate actually failed (${gateList.join(', ')}) — that is a real budget or path problem, not a slow advisory hook; widen the failing gate's budget or fix that step.`,
|
|
208
239
|
);
|
|
209
240
|
}
|
|
241
|
+
const launchList = [...launchFailures].sort();
|
|
242
|
+
if (launchList.length > 0) {
|
|
243
|
+
escalations.push(`.sh hook(s) could not be launched: ${launchList.join(', ')}`);
|
|
244
|
+
advices.push(LAUNCH_REMEDY);
|
|
245
|
+
}
|
|
210
246
|
if (budgetExhaustedChains > 0) {
|
|
211
247
|
escalations.push(`${budgetExhaustedChains} chain row(s) exhausted the total budget`);
|
|
212
248
|
advices.push(
|
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
|
|