@ngockhoale/ukit 2.6.2 → 2.6.4
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 +97 -0
- package/package.json +1 -1
- package/scripts/bench/parallel-agents.mjs +42 -4
- package/scripts/release/verify-release.mjs +43 -6
- package/src/core/applyPlan.js +65 -0
- package/src/core/compact/threshold.js +33 -30
- package/src/core/fileOps.js +9 -2
- package/src/core/runInstallPipeline.js +10 -1
- package/src/core/status.js +1 -1
- package/templates/.claude/hooks/auto-allow-bash.sh +29 -20
- package/templates/.claude/hooks/auto-prune-bash.sh +48 -30
- package/templates/.claude/hooks/completion-gate.sh +22 -1
- package/templates/.claude/hooks/context-hardcap-gate.sh +19 -4
- package/templates/.claude/hooks/handoff-model-guard.sh +10 -1
- package/templates/.claude/hooks/handoff-resume.sh +19 -17
- package/templates/.claude/hooks/post-edit-verify.sh +1 -0
- package/templates/.claude/hooks/pre-edit-backup.sh +1 -0
- package/templates/.claude/hooks/reset-compact-pressure.sh +39 -22
- package/templates/.claude/hooks/stale-spec-guard.sh +1 -0
- package/templates/.claude/hooks/verification-guard.sh +17 -1
- package/templates/.claude/settings.json +4 -4
- package/templates/.claude/ukit/index/extract-image.mjs +49 -0
- package/templates/.claude/ukit/runtime/compact-threshold.mjs +62 -40
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +34 -5
- package/templates/.claude/ukit/runtime/hook-chain-budget.mjs +11 -4
- package/templates/.claude/ukit/runtime/hook-telemetry.mjs +7 -3
- package/templates/.claude/ukit/runtime/output-compression.mjs +16 -5
- package/templates/.claude/ukit/runtime/reinject-context.mjs +2 -2
- package/templates/.claude/ukit/runtime/stop-coordinator.mjs +3 -0
- package/templates/.claude/ukit/runtime/token-utils.mjs +9 -2
- package/templates/.omp/hooks/pre/ukit-bridge.js +72 -4
- package/templates/ukit/storage/config.json +2 -0
|
@@ -65,7 +65,16 @@ PROGRESS_FILE="$PROJECT_ROOT/.claude/ukit/verification-progress.json"
|
|
|
65
65
|
INPUT_FILE="$UKIT_INPUT_FILE" STATE_FILE="$STATE_FILE" ROUTE_CACHE_FILE="$ROUTE_CACHE_FILE" PROGRESS_FILE="$PROGRESS_FILE" UKIT_RUNTIME_DIR="$SCRIPT_DIR/../ukit/runtime" node <<'NODE'
|
|
66
66
|
const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10) || 3000;
|
|
67
67
|
const LOCK_STARTED_AT = Date.now();
|
|
68
|
-
|
|
68
|
+
// SPEC §8: a timed-out gate silently passes work it never evaluated — announce the
|
|
69
|
+
// degrade (fail-open posture kept: still exit 0).
|
|
70
|
+
setTimeout(() => {
|
|
71
|
+
try {
|
|
72
|
+
process.stdout.write(JSON.stringify({
|
|
73
|
+
systemMessage: `UKit verification-guard: evaluation exceeded its ${HOOK_DEADLINE_MS}ms deadline; this command was allowed without verification tracking.`,
|
|
74
|
+
}) + '\n');
|
|
75
|
+
} catch {}
|
|
76
|
+
process.exit(0);
|
|
77
|
+
}, HOOK_DEADLINE_MS).unref();
|
|
69
78
|
// TASK-015 fix round 1: the awaitable fs surface. Every state/progress read and
|
|
70
79
|
// the atomic progress mutation is awaited so the unref'd self-deadline above can
|
|
71
80
|
// always preempt it; synchronous fs calls park the event loop instead.
|
|
@@ -544,6 +553,13 @@ process.exit(0);
|
|
|
544
553
|
})().catch((err) => {
|
|
545
554
|
// Advisory hook: an unexpected error (including a rejected lock) must never
|
|
546
555
|
// turn into a non-zero exit — degrade to a bounded diagnostic and exit 0.
|
|
556
|
+
// SPEC §8: the degrade is emitted on stdout too — stderr alone may never reach
|
|
557
|
+
// the user, and a wedged gate must not look identical to a clean pass.
|
|
558
|
+
try {
|
|
559
|
+
process.stdout.write(JSON.stringify({
|
|
560
|
+
systemMessage: `UKit verification-guard: unexpected error (${err?.message ?? err}); command allowed without verification tracking.`,
|
|
561
|
+
}) + '\n');
|
|
562
|
+
} catch {}
|
|
547
563
|
try {
|
|
548
564
|
process.stderr.write(`verification-guard: unexpected error (${err?.message ?? err})\n`);
|
|
549
565
|
} catch {}
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
{
|
|
74
74
|
"type": "command",
|
|
75
75
|
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/stale-spec-guard.sh\"",
|
|
76
|
-
"timeout":
|
|
76
|
+
"timeout": 11
|
|
77
77
|
},
|
|
78
78
|
{
|
|
79
79
|
"type": "command",
|
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
{
|
|
84
84
|
"type": "command",
|
|
85
85
|
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/skill-router.sh\"",
|
|
86
|
-
"timeout":
|
|
86
|
+
"timeout": 20
|
|
87
87
|
},
|
|
88
88
|
{
|
|
89
89
|
"type": "command",
|
|
@@ -103,7 +103,7 @@
|
|
|
103
103
|
{
|
|
104
104
|
"type": "command",
|
|
105
105
|
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/auto-allow-bash.sh\"",
|
|
106
|
-
"timeout":
|
|
106
|
+
"timeout": 15
|
|
107
107
|
},
|
|
108
108
|
{
|
|
109
109
|
"type": "command",
|
|
@@ -191,7 +191,7 @@
|
|
|
191
191
|
{
|
|
192
192
|
"type": "command",
|
|
193
193
|
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/skill-router.sh\"",
|
|
194
|
-
"timeout":
|
|
194
|
+
"timeout": 20
|
|
195
195
|
},
|
|
196
196
|
{
|
|
197
197
|
"type": "command",
|
|
@@ -46,6 +46,12 @@ const DEFAULT_LIMIT = 3;
|
|
|
46
46
|
const MAX_DECODED_BYTES = 10 * 1024 * 1024; // 10 MB
|
|
47
47
|
const RETENTION_MAX_IMAGE_FILES = 20;
|
|
48
48
|
const MARKER_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24h
|
|
49
|
+
// analyzed-<sha>.json receipts outlive their marker dir: deleting the dir at
|
|
50
|
+
// 24h also deletes the receipt isAlreadyAnalyzed() reads, which re-arms
|
|
51
|
+
// pending-<sha>.json for an already-analyzed image and re-fires the vision
|
|
52
|
+
// gate every sweep (BUG-C22-14). Receipts get their own longer TTL, shared
|
|
53
|
+
// with the legacy top-level analyzed-*.json pruner (BUG-C22-18).
|
|
54
|
+
const ANALYZED_RECEIPT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30d
|
|
49
55
|
|
|
50
56
|
/**
|
|
51
57
|
* Single shared hash function for both the image filename sha and the
|
|
@@ -491,6 +497,23 @@ function pruneImageFiles(outDir) {
|
|
|
491
497
|
// ignore
|
|
492
498
|
}
|
|
493
499
|
}
|
|
500
|
+
|
|
501
|
+
// BUG-C22-18: legacy top-level analyzed-<sha>.json receipts match neither
|
|
502
|
+
// pruner and never converge — remove them once past the shared receipt TTL.
|
|
503
|
+
// Safe: isAlreadyAnalyzed() only reads receipts INSIDE marker dirs; nothing
|
|
504
|
+
// consults the top-level orphans.
|
|
505
|
+
const now = Date.now();
|
|
506
|
+
for (const e of entries) {
|
|
507
|
+
if (!e.isFile() || !e.name.startsWith('analyzed-') || !e.name.endsWith('.json')) continue;
|
|
508
|
+
try {
|
|
509
|
+
const full = path.join(outDir, e.name);
|
|
510
|
+
if (now - fs.statSync(full).mtimeMs > ANALYZED_RECEIPT_MAX_AGE_MS) {
|
|
511
|
+
fs.unlinkSync(full);
|
|
512
|
+
}
|
|
513
|
+
} catch {
|
|
514
|
+
// ignore
|
|
515
|
+
}
|
|
516
|
+
}
|
|
494
517
|
}
|
|
495
518
|
|
|
496
519
|
function pruneMarkerDirs(outDir) {
|
|
@@ -511,6 +534,10 @@ function pruneMarkerDirs(outDir) {
|
|
|
511
534
|
continue;
|
|
512
535
|
}
|
|
513
536
|
if (now - mtimeMs > MARKER_MAX_AGE_MS) {
|
|
537
|
+
// BUG-C22-14: a dir holding an analyzed-<sha>.json receipt younger than
|
|
538
|
+
// the receipt TTL is kept — deleting it would erase the only record
|
|
539
|
+
// isAlreadyAnalyzed() reads and re-arm the vision gate every sweep.
|
|
540
|
+
if (hasLiveAnalyzedReceipt(full, now)) continue;
|
|
514
541
|
try {
|
|
515
542
|
fs.rmSync(full, { recursive: true, force: true });
|
|
516
543
|
} catch {
|
|
@@ -520,6 +547,28 @@ function pruneMarkerDirs(outDir) {
|
|
|
520
547
|
}
|
|
521
548
|
}
|
|
522
549
|
|
|
550
|
+
// Returns true when the marker dir still carries an analyzed-<sha>.json receipt
|
|
551
|
+
// inside its TTL — the receipt outlives the 24h dir sweep so the analyzed-sha
|
|
552
|
+
// filter keeps suppressing re-arms until the receipt itself ages out.
|
|
553
|
+
function hasLiveAnalyzedReceipt(markerDir, now) {
|
|
554
|
+
let names;
|
|
555
|
+
try {
|
|
556
|
+
names = fs.readdirSync(markerDir);
|
|
557
|
+
} catch {
|
|
558
|
+
return false;
|
|
559
|
+
}
|
|
560
|
+
for (const name of names) {
|
|
561
|
+
if (!name.startsWith('analyzed-') || !name.endsWith('.json')) continue;
|
|
562
|
+
try {
|
|
563
|
+
const mtimeMs = fs.statSync(path.join(markerDir, name)).mtimeMs;
|
|
564
|
+
if (now - mtimeMs <= ANALYZED_RECEIPT_MAX_AGE_MS) return true;
|
|
565
|
+
} catch {
|
|
566
|
+
// ignore
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
return false;
|
|
570
|
+
}
|
|
571
|
+
|
|
523
572
|
function printResult(opts, result) {
|
|
524
573
|
if (opts.json) {
|
|
525
574
|
console.log(JSON.stringify(result));
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
|
-
import fsSync from 'node:fs';
|
|
3
2
|
import path from 'node:path';
|
|
4
3
|
import { pathToFileURL } from 'node:url';
|
|
5
4
|
import {
|
|
@@ -456,13 +455,17 @@ export const CAPACITY_RECORD_SEGMENTS = ['.ukit', 'storage', 'cache', 'context-c
|
|
|
456
455
|
// smaller verified window moves the advisory phases down with the cap they precede.
|
|
457
456
|
export const SOFT_TO_CAP_RATIO = 0.3;
|
|
458
457
|
|
|
459
|
-
|
|
458
|
+
// BUG-C22-01: async on purpose — every consumer of this module runs inside an
|
|
459
|
+
// unref'd hook deadline (context-hardcap-gate.sh, skill-router's prompt CLI). A
|
|
460
|
+
// synchronous readFileSync on a stalled .ukit mount parks the event loop so the
|
|
461
|
+
// armed deadline can never fire (C21-04 class). Callers must await.
|
|
462
|
+
export async function readContextCapacityRecord(projectRoot) {
|
|
460
463
|
const root = projectRoot
|
|
461
464
|
|| process.env.CLAUDE_PROJECT_DIR
|
|
462
465
|
|| process.env.PROJECT_ROOT
|
|
463
466
|
|| process.cwd();
|
|
464
467
|
try {
|
|
465
|
-
const parsed = JSON.parse(
|
|
468
|
+
const parsed = JSON.parse(await fs.readFile(path.join(root, ...CAPACITY_RECORD_SEGMENTS), 'utf8'));
|
|
466
469
|
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
467
470
|
} catch {
|
|
468
471
|
return null;
|
|
@@ -471,8 +474,8 @@ export function readContextCapacityRecord(projectRoot) {
|
|
|
471
474
|
|
|
472
475
|
// Negotiated capacity: env override > config override > the model the guard last observed >
|
|
473
476
|
// conservative fallback. Exported so the source twin and diagnostics read the same number.
|
|
474
|
-
export function resolveCompactCapacity(config = {}) {
|
|
475
|
-
const record = readContextCapacityRecord(config?.projectRoot);
|
|
477
|
+
export async function resolveCompactCapacity(config = {}) {
|
|
478
|
+
const record = await readContextCapacityRecord(config?.projectRoot);
|
|
476
479
|
return resolveContextCapTokens({
|
|
477
480
|
env: process.env,
|
|
478
481
|
config,
|
|
@@ -480,13 +483,13 @@ export function resolveCompactCapacity(config = {}) {
|
|
|
480
483
|
});
|
|
481
484
|
}
|
|
482
485
|
|
|
483
|
-
export function buildCompactThresholds(config = {}) {
|
|
486
|
+
export async function buildCompactThresholds(config = {}) {
|
|
484
487
|
const explicitSoftThreshold = finiteNumber(config?.compact?.tokenThreshold, 0);
|
|
485
488
|
const shippedHardCap = positiveInteger(config?.compact?.hardCapTokens, 500_000);
|
|
486
489
|
// Only tighten once a record exists: an install with no evidence yet (and every caller
|
|
487
490
|
// that passes no project root) keeps the shipped tuning unchanged.
|
|
488
|
-
const record = readContextCapacityRecord(config?.projectRoot);
|
|
489
|
-
const negotiated = record ? resolveCompactCapacity(config) : null;
|
|
491
|
+
const record = await readContextCapacityRecord(config?.projectRoot);
|
|
492
|
+
const negotiated = record ? await resolveCompactCapacity(config) : null;
|
|
490
493
|
const hardCapTokens = negotiated
|
|
491
494
|
? Math.max(1, Math.min(shippedHardCap, negotiated.capTokens))
|
|
492
495
|
: shippedHardCap;
|
|
@@ -511,16 +514,16 @@ export function buildCompactThresholds(config = {}) {
|
|
|
511
514
|
|
|
512
515
|
// Absolute, non-negotiable ceiling — separate from the soft/hard advisory phases above.
|
|
513
516
|
// Crossing this means the advisory nudges were not followed; real compaction is mandatory.
|
|
514
|
-
export function isHardCapExceeded(estimatedTotalTokens, config = {}) {
|
|
515
|
-
const { hardCapTokens } = buildCompactThresholds(config);
|
|
517
|
+
export async function isHardCapExceeded(estimatedTotalTokens, config = {}) {
|
|
518
|
+
const { hardCapTokens } = await buildCompactThresholds(config);
|
|
516
519
|
return finiteNumber(estimatedTotalTokens, 0) >= hardCapTokens;
|
|
517
520
|
}
|
|
518
521
|
|
|
519
522
|
// Called from the PreCompact hook path once real compaction has happened, so the
|
|
520
523
|
// hard-cap gate unblocks on the next tool call instead of staying tripped forever.
|
|
521
|
-
export function resetCompactPressureState(rawState = {}, config = {}) {
|
|
522
|
-
const current = buildCompactPressureState(rawState, config);
|
|
523
|
-
return buildCompactPressureState({
|
|
524
|
+
export async function resetCompactPressureState(rawState = {}, config = {}) {
|
|
525
|
+
const current = await buildCompactPressureState(rawState, config);
|
|
526
|
+
return await buildCompactPressureState({
|
|
524
527
|
...current,
|
|
525
528
|
updatedAt: Date.now(),
|
|
526
529
|
sessionTokens: 0,
|
|
@@ -638,7 +641,7 @@ export async function syncCompactPressureStateWithTranscript(
|
|
|
638
641
|
}
|
|
639
642
|
return {
|
|
640
643
|
state: {
|
|
641
|
-
...resetCompactPressureState(rawState ?? {}, config),
|
|
644
|
+
...(await resetCompactPressureState(rawState ?? {}, config)),
|
|
642
645
|
lastCompactBoundaryAt: boundaryAt,
|
|
643
646
|
transcriptBoundaryProbe: probe,
|
|
644
647
|
},
|
|
@@ -647,7 +650,7 @@ export async function syncCompactPressureStateWithTranscript(
|
|
|
647
650
|
};
|
|
648
651
|
}
|
|
649
652
|
|
|
650
|
-
function buildReleaseThresholds(thresholds
|
|
653
|
+
function buildReleaseThresholds(thresholds) {
|
|
651
654
|
const softReleaseThreshold = Math.max(
|
|
652
655
|
Math.min(thresholds.softThreshold, Math.round(thresholds.softThreshold * SOFT_RELEASE_RATIO)),
|
|
653
656
|
1,
|
|
@@ -663,11 +666,12 @@ function buildReleaseThresholds(thresholds = buildCompactThresholds()) {
|
|
|
663
666
|
};
|
|
664
667
|
}
|
|
665
668
|
|
|
666
|
-
export function resolveCompactPhase(
|
|
669
|
+
export async function resolveCompactPhase(
|
|
667
670
|
estimatedTotalTokens,
|
|
668
|
-
thresholds =
|
|
671
|
+
thresholds = null,
|
|
669
672
|
previousState = {},
|
|
670
673
|
) {
|
|
674
|
+
thresholds = thresholds ?? await buildCompactThresholds();
|
|
671
675
|
const previousPhase = normalizePhase(previousState?.phase ?? previousState);
|
|
672
676
|
const releaseThresholds = buildReleaseThresholds(thresholds);
|
|
673
677
|
if (estimatedTotalTokens >= thresholds.hardThreshold) {
|
|
@@ -689,7 +693,7 @@ function resolvePhaseCooldownMs(phase) {
|
|
|
689
693
|
return PHASE_COOLDOWN_MS[normalizePhase(phase)] ?? 0;
|
|
690
694
|
}
|
|
691
695
|
|
|
692
|
-
function resolvePlanDeltaThreshold(phase, thresholds
|
|
696
|
+
function resolvePlanDeltaThreshold(phase, thresholds) {
|
|
693
697
|
return normalizePhase(phase) === 'hard'
|
|
694
698
|
? Math.max(60, Math.round(thresholds.hardThreshold * 0.14))
|
|
695
699
|
: Math.max(40, Math.round(thresholds.softThreshold * 0.12));
|
|
@@ -821,9 +825,9 @@ function projectPressureDocument(sessions) {
|
|
|
821
825
|
};
|
|
822
826
|
}
|
|
823
827
|
|
|
824
|
-
export function buildCompactPressureState(rawState = null, config = {}) {
|
|
828
|
+
export async function buildCompactPressureState(rawState = null, config = {}) {
|
|
825
829
|
rawState = resolvePressureRecord(rawState, config);
|
|
826
|
-
const thresholds = buildCompactThresholds(config);
|
|
830
|
+
const thresholds = await buildCompactThresholds(config);
|
|
827
831
|
const rawSoftThreshold = finiteNumber(rawState?.softThreshold, 0);
|
|
828
832
|
const rawHardThreshold = finiteNumber(rawState?.hardThreshold, 0);
|
|
829
833
|
if (!finiteNumber(config?.compact?.tokenThreshold, 0) && rawSoftThreshold > 0) {
|
|
@@ -872,7 +876,7 @@ export function buildCompactPressureState(rawState = null, config = {}) {
|
|
|
872
876
|
estimatedContextTokens,
|
|
873
877
|
sessionTokens,
|
|
874
878
|
estimatedTotalTokens,
|
|
875
|
-
phase: resolveCompactPhase(estimatedTotalTokens, thresholds, rawState),
|
|
879
|
+
phase: await resolveCompactPhase(estimatedTotalTokens, thresholds, rawState),
|
|
876
880
|
taskMode,
|
|
877
881
|
cooldownUntil: finiteNumber(rawState?.cooldownUntil, 0),
|
|
878
882
|
lastCompactBoundaryAt: finiteNumber(rawState?.lastCompactBoundaryAt, 0),
|
|
@@ -888,14 +892,14 @@ export function buildCompactPressureState(rawState = null, config = {}) {
|
|
|
888
892
|
};
|
|
889
893
|
}
|
|
890
894
|
|
|
891
|
-
export function registerPromptPressure(state, {
|
|
895
|
+
export async function registerPromptPressure(state, {
|
|
892
896
|
promptText = '',
|
|
893
897
|
routingContext = {},
|
|
894
898
|
previousContext = null,
|
|
895
899
|
routeSummary = null,
|
|
896
900
|
timestamp = Date.now(),
|
|
897
901
|
} = {}, config = {}) {
|
|
898
|
-
const current = buildCompactPressureState(state, config);
|
|
902
|
+
const current = await buildCompactPressureState(state, config);
|
|
899
903
|
const normalizedPrompt = String(promptText || routingContext?.lastExplicitUserPromptText || '').trim();
|
|
900
904
|
if (!normalizedPrompt) {
|
|
901
905
|
return current;
|
|
@@ -916,7 +920,7 @@ export function registerPromptPressure(state, {
|
|
|
916
920
|
})
|
|
917
921
|
: current.recentPrompts;
|
|
918
922
|
|
|
919
|
-
return buildCompactPressureState({
|
|
923
|
+
return await buildCompactPressureState({
|
|
920
924
|
...current,
|
|
921
925
|
updatedAt: timestamp,
|
|
922
926
|
routingContext,
|
|
@@ -936,7 +940,7 @@ export function registerPromptPressure(state, {
|
|
|
936
940
|
}, config);
|
|
937
941
|
}
|
|
938
942
|
|
|
939
|
-
export function registerOutputPressure(state, {
|
|
943
|
+
export async function registerOutputPressure(state, {
|
|
940
944
|
command = '',
|
|
941
945
|
profile = 'generic',
|
|
942
946
|
summary = '',
|
|
@@ -946,7 +950,7 @@ export function registerOutputPressure(state, {
|
|
|
946
950
|
exitCode = null,
|
|
947
951
|
timestamp = Date.now(),
|
|
948
952
|
} = {}, config = {}) {
|
|
949
|
-
const current = buildCompactPressureState(state, config);
|
|
953
|
+
const current = await buildCompactPressureState(state, config);
|
|
950
954
|
const normalizedSummary = summarizeOutputText(summary, command);
|
|
951
955
|
if (!normalizedSummary) {
|
|
952
956
|
return current;
|
|
@@ -968,7 +972,7 @@ export function registerOutputPressure(state, {
|
|
|
968
972
|
keyBuilder: (candidate) => `${candidate.command ?? ''}|${candidate.summary ?? ''}`,
|
|
969
973
|
});
|
|
970
974
|
|
|
971
|
-
return buildCompactPressureState({
|
|
975
|
+
return await buildCompactPressureState({
|
|
972
976
|
...current,
|
|
973
977
|
updatedAt: timestamp,
|
|
974
978
|
recentOutputs,
|
|
@@ -982,8 +986,8 @@ export function registerOutputPressure(state, {
|
|
|
982
986
|
}, config);
|
|
983
987
|
}
|
|
984
988
|
|
|
985
|
-
export function registerThresholdCompactPlan(state, plan, config = {}) {
|
|
986
|
-
const current = buildCompactPressureState(state, config);
|
|
989
|
+
export async function registerThresholdCompactPlan(state, plan, config = {}) {
|
|
990
|
+
const current = await buildCompactPressureState(state, config);
|
|
987
991
|
if (!plan?.active) {
|
|
988
992
|
return current;
|
|
989
993
|
}
|
|
@@ -999,7 +1003,7 @@ export function registerThresholdCompactPlan(state, plan, config = {}) {
|
|
|
999
1003
|
lines: plan.lines,
|
|
1000
1004
|
});
|
|
1001
1005
|
if (current.latestPlan?.fingerprint === fingerprint) {
|
|
1002
|
-
return buildCompactPressureState({
|
|
1006
|
+
return await buildCompactPressureState({
|
|
1003
1007
|
...current,
|
|
1004
1008
|
updatedAt: now,
|
|
1005
1009
|
latestPlan: {
|
|
@@ -1017,7 +1021,7 @@ export function registerThresholdCompactPlan(state, plan, config = {}) {
|
|
|
1017
1021
|
const materialChange = isMaterialThresholdPlanChange(current.latestPlan, plan, thresholds);
|
|
1018
1022
|
|
|
1019
1023
|
if (withinCooldown && !materialChange) {
|
|
1020
|
-
return buildCompactPressureState({
|
|
1024
|
+
return await buildCompactPressureState({
|
|
1021
1025
|
...current,
|
|
1022
1026
|
updatedAt: now,
|
|
1023
1027
|
latestPlan: {
|
|
@@ -1052,7 +1056,7 @@ export function registerThresholdCompactPlan(state, plan, config = {}) {
|
|
|
1052
1056
|
? Math.min(current.sessionTokens, decayTarget)
|
|
1053
1057
|
: current.sessionTokens;
|
|
1054
1058
|
|
|
1055
|
-
return buildCompactPressureState({
|
|
1059
|
+
return await buildCompactPressureState({
|
|
1056
1060
|
...current,
|
|
1057
1061
|
updatedAt: now,
|
|
1058
1062
|
sessionTokens: decayedSessionTokens,
|
|
@@ -1073,7 +1077,7 @@ export function registerThresholdCompactPlan(state, plan, config = {}) {
|
|
|
1073
1077
|
}, config);
|
|
1074
1078
|
}
|
|
1075
1079
|
|
|
1076
|
-
export function buildThresholdCompactPlan({
|
|
1080
|
+
export async function buildThresholdCompactPlan({
|
|
1077
1081
|
state,
|
|
1078
1082
|
config = {},
|
|
1079
1083
|
routeState = {},
|
|
@@ -1081,8 +1085,8 @@ export function buildThresholdCompactPlan({
|
|
|
1081
1085
|
dynamicLines = [],
|
|
1082
1086
|
maxTokens = 280,
|
|
1083
1087
|
} = {}) {
|
|
1084
|
-
const normalizedState = buildCompactPressureState(state, config);
|
|
1085
|
-
const thresholds = buildCompactThresholds(config);
|
|
1088
|
+
const normalizedState = await buildCompactPressureState(state, config);
|
|
1089
|
+
const thresholds = await buildCompactThresholds(config);
|
|
1086
1090
|
const phase = normalizedState.phase;
|
|
1087
1091
|
if (phase === 'monitor') {
|
|
1088
1092
|
return {
|
|
@@ -1205,7 +1209,7 @@ export function buildThresholdCompactPlan({
|
|
|
1205
1209
|
|
|
1206
1210
|
export async function readCompactPressureState(projectRoot, config = {}) {
|
|
1207
1211
|
const runtimePaths = buildRuntimePaths(projectRoot);
|
|
1208
|
-
return buildCompactPressureState(await readJson(runtimePaths.compactPressurePath, null), config);
|
|
1212
|
+
return await buildCompactPressureState(await readJson(runtimePaths.compactPressurePath, null), config);
|
|
1209
1213
|
}
|
|
1210
1214
|
|
|
1211
1215
|
// All compact-pressure mutations share one lock on the state file: without it,
|
|
@@ -1225,9 +1229,9 @@ async function mutateCompactPressureState(projectRoot, mutator, config = {}) {
|
|
|
1225
1229
|
return withFileLock(runtimePaths.compactPressurePath, async () => {
|
|
1226
1230
|
const sessions = readPressureDocument(await readJson(runtimePaths.compactPressurePath, null));
|
|
1227
1231
|
const { id, record } = pickPressureSession(sessions, normalizeSessionId(config?.sessionId));
|
|
1228
|
-
const current = buildCompactPressureState(record, scopedConfig);
|
|
1229
|
-
const next = mutator(current);
|
|
1230
|
-
const normalized = buildCompactPressureState(next, scopedConfig);
|
|
1232
|
+
const current = await buildCompactPressureState(record, scopedConfig);
|
|
1233
|
+
const next = await mutator(current);
|
|
1234
|
+
const normalized = await buildCompactPressureState(next, scopedConfig);
|
|
1231
1235
|
sessions[id] = { ...normalized, updatedAt: Date.now() };
|
|
1232
1236
|
await writeJson(runtimePaths.compactPressurePath, projectPressureDocument(sessions));
|
|
1233
1237
|
return normalized;
|
|
@@ -1240,7 +1244,7 @@ export async function writeCompactPressureState(projectRoot, state, config = {})
|
|
|
1240
1244
|
return withFileLock(runtimePaths.compactPressurePath, async () => {
|
|
1241
1245
|
const sessions = readPressureDocument(await readJson(runtimePaths.compactPressurePath, null));
|
|
1242
1246
|
const { id } = pickPressureSession(sessions, normalizeSessionId(config?.sessionId));
|
|
1243
|
-
const normalized = buildCompactPressureState(state, scopedConfig);
|
|
1247
|
+
const normalized = await buildCompactPressureState(state, scopedConfig);
|
|
1244
1248
|
sessions[id] = { ...normalized, updatedAt: Date.now() };
|
|
1245
1249
|
await writeJson(runtimePaths.compactPressurePath, projectPressureDocument(sessions));
|
|
1246
1250
|
return normalized;
|
|
@@ -1306,6 +1310,24 @@ function extractPrompt(payload = {}) {
|
|
|
1306
1310
|
}
|
|
1307
1311
|
|
|
1308
1312
|
async function runCli() {
|
|
1313
|
+
// BUG-C22-15: this CLI is spawned unbounded by skill-router.sh — every read it
|
|
1314
|
+
// does (config, pressure record, transcript tail, capacity record) sits on the
|
|
1315
|
+
// .ukit/transcript filesystem, so a stalled mount used to hang the child forever
|
|
1316
|
+
// (the shell `|| true` swallows exit codes, not time, and the host SIGKILLs
|
|
1317
|
+
// silently). Arm a self-deadline so ALL invokers get a bounded, announced exit.
|
|
1318
|
+
const CLI_DEADLINE_MS = Number.parseInt(process.env.UKIT_COMPACT_CLI_DEADLINE_MS || '', 10) || 4000;
|
|
1319
|
+
const cliDeadline = setTimeout(() => {
|
|
1320
|
+
try {
|
|
1321
|
+
process.stdout.write(
|
|
1322
|
+
`[ukit-skill-router] compact-threshold prompt CLI exceeded its ${CLI_DEADLINE_MS}ms deadline — `
|
|
1323
|
+
+ 'pressure bookkeeping skipped this pass (likely stalled filesystem). The hook continues unharmed; '
|
|
1324
|
+
+ 'if this repeats, check the .ukit volume or raise UKIT_COMPACT_CLI_DEADLINE_MS.\n',
|
|
1325
|
+
);
|
|
1326
|
+
} catch { /* stdout already gone */ }
|
|
1327
|
+
process.exit(0);
|
|
1328
|
+
}, CLI_DEADLINE_MS);
|
|
1329
|
+
cliDeadline.unref();
|
|
1330
|
+
|
|
1309
1331
|
const mode = String(process.argv[2] ?? '').trim();
|
|
1310
1332
|
if (!mode) {
|
|
1311
1333
|
return;
|
|
@@ -149,17 +149,25 @@ export async function sweepExecLedgerDir(dir, {
|
|
|
149
149
|
maxEntries = LEDGER_SWEEP_MAX_ENTRIES,
|
|
150
150
|
maxRemovals = LEDGER_SWEEP_MAX_REMOVALS,
|
|
151
151
|
} = {}) {
|
|
152
|
-
let
|
|
152
|
+
let dirents;
|
|
153
153
|
try {
|
|
154
|
-
|
|
154
|
+
dirents = await fs.readdir(dir, { withFileTypes: true });
|
|
155
155
|
} catch {
|
|
156
156
|
return { scanned: 0, removed: 0 };
|
|
157
157
|
}
|
|
158
158
|
const cutoff = now() - maxAgeMs;
|
|
159
159
|
const countCapCutoff = now() - LEDGER_COUNT_CAP_MIN_AGE_MS;
|
|
160
|
+
// BUG-C22-12: eligible count comes from the dirents (one bounded readdir, no
|
|
161
|
+
// per-name stat) so the count cap is enforceable even though
|
|
162
|
+
// maxFiles > maxEntries — capping `scanned` first made `entries.length` an
|
|
163
|
+
// under-count and `overflow` was always 0.
|
|
164
|
+
const eligibleNames = [];
|
|
165
|
+
for (const dirent of dirents) {
|
|
166
|
+
if (dirent.isFile()) eligibleNames.push(dirent.name);
|
|
167
|
+
}
|
|
160
168
|
const entries = [];
|
|
161
169
|
let scanned = 0;
|
|
162
|
-
for (const name of
|
|
170
|
+
for (const name of eligibleNames) {
|
|
163
171
|
if (scanned >= maxEntries) break;
|
|
164
172
|
scanned += 1;
|
|
165
173
|
try {
|
|
@@ -169,9 +177,9 @@ export async function sweepExecLedgerDir(dir, {
|
|
|
169
177
|
}
|
|
170
178
|
entries.sort((a, b) => a.mtimeMs - b.mtimeMs); // oldest first
|
|
171
179
|
// Overflow counts eligible FILES only — foreign entries (`.lock` dirs, other
|
|
172
|
-
// state files sharing the dir) inflate
|
|
180
|
+
// state files sharing the dir) inflate the dir listing and would evict real
|
|
173
181
|
// ledgers while the dir is actually under the cap.
|
|
174
|
-
const overflow = Math.max(0,
|
|
182
|
+
const overflow = Math.max(0, eligibleNames.length - maxFiles);
|
|
175
183
|
let removed = 0;
|
|
176
184
|
for (const entry of entries) {
|
|
177
185
|
if (removed >= maxRemovals) break;
|
|
@@ -1357,6 +1365,22 @@ export function evaluateCompletion({ state = {}, ledger = {} } = {}) {
|
|
|
1357
1365
|
// (e.g. the omp bridge for a session it does not own) must not be force-notified.
|
|
1358
1366
|
return { continue: false, notify: false, missingEvidence: [] };
|
|
1359
1367
|
}
|
|
1368
|
+
// An informational turn carries no completion contract of its own. Two paths land here:
|
|
1369
|
+
// a fully-routed informational prompt (routeSummary.executionMode === 'informational',
|
|
1370
|
+
// contract null) and a no-route side-question that CARRIES the previous prompt's
|
|
1371
|
+
// routeSummary + contract forward — skill-router marks exactly this case via
|
|
1372
|
+
// routingContext.executionMode === 'informational' (skill-router.sh no-route branch).
|
|
1373
|
+
// Enforcing the carried write+verify debt against a question turn is a guaranteed
|
|
1374
|
+
// self-block: the prompt re-key empties the effective ledger (sameRequest below), so a
|
|
1375
|
+
// mid-session status question like "còn task nào dang dở không" can never produce the
|
|
1376
|
+
// evidence the old contract demands. Release silently — the carried routeSummary stays
|
|
1377
|
+
// on disk, so the previous contract still survives until a real route replaces it.
|
|
1378
|
+
if (
|
|
1379
|
+
mode === 'informational'
|
|
1380
|
+
|| state?.routingContext?.executionMode === 'informational'
|
|
1381
|
+
) {
|
|
1382
|
+
return { continue: false, notify: false, complete: true, missingEvidence: [] };
|
|
1383
|
+
}
|
|
1360
1384
|
if (evidence.length === 0) {
|
|
1361
1385
|
// A route with no completion evidence has no actionable block reason — blocking would
|
|
1362
1386
|
// loop unboundedly. Release loud instead of ending indistinguishable from a stall.
|
|
@@ -1685,6 +1709,11 @@ function isDirectRun() {
|
|
|
1685
1709
|
const selfPath = fileURLToPath(import.meta.url);
|
|
1686
1710
|
const resolved = path.resolve(argvPath);
|
|
1687
1711
|
if (selfPath === resolved) return true;
|
|
1712
|
+
// BUG-C22-01: a stalled mount must not park the event loop during `await import`
|
|
1713
|
+
// — importing this module inside an armed hook deadline used to run realpathSync
|
|
1714
|
+
// here for EVERY importer. Skip fs entirely when the basename cannot match (any
|
|
1715
|
+
// importer argv like reinject-context.mjs differs from execution-ledger.mjs).
|
|
1716
|
+
if (path.basename(selfPath) !== path.basename(resolved)) return false;
|
|
1688
1717
|
try {
|
|
1689
1718
|
return fsSync.realpathSync(selfPath) === fsSync.realpathSync(resolved);
|
|
1690
1719
|
} catch {
|
|
@@ -17,13 +17,20 @@
|
|
|
17
17
|
//
|
|
18
18
|
// The clamps are intentionally generous (up to 120s) because they are an
|
|
19
19
|
// explicit operator/test affordance, not a default: an unconfigured chain still
|
|
20
|
-
// resolves to the 10s/
|
|
21
|
-
//
|
|
20
|
+
// resolves to the 10s/12s/52s defaults documented below.
|
|
21
|
+
//
|
|
22
|
+
// BUG-C22-25: the child fallback MUST cover a child's own internal worst case —
|
|
23
|
+
// every staged .sh hook stages stdin (UKIT_HOOK_STAGE_MS default 2000) then arms
|
|
24
|
+
// a node deadline of 3000-8000ms, so a child legitimately needs up to ~10s. The
|
|
25
|
+
// old 4000ms fallback killed legal hooks mid-evaluation (recorded
|
|
26
|
+
// failureKind:'timeout'; on tool_call chains the killed script can be a
|
|
27
|
+
// FAIL_CLOSED gate). 12000 = 2000 stage + 8000 deadline + 2000 margin. The
|
|
28
|
+
// ceiling fallback scales with it: 4 chained children × 12000 + 4000 slack.
|
|
22
29
|
|
|
23
30
|
export const CHAIN_BUDGET_SPECS = {
|
|
24
31
|
base: { name: 'UKIT_HOOK_CHAIN_BASE_MS', fallback: 10000, min: 500, max: 60000 },
|
|
25
|
-
child: { name: 'UKIT_HOOK_CHILD_BUDGET_MS', fallback:
|
|
26
|
-
ceiling: { name: 'UKIT_HOOK_CHAIN_CEILING_MS', fallback:
|
|
32
|
+
child: { name: 'UKIT_HOOK_CHILD_BUDGET_MS', fallback: 12000, min: 100, max: 60000 },
|
|
33
|
+
ceiling: { name: 'UKIT_HOOK_CHAIN_CEILING_MS', fallback: 52000, min: 500, max: 120000 },
|
|
27
34
|
};
|
|
28
35
|
|
|
29
36
|
// Margin the outer timeout adds on top of the runner's own budget: enough to
|
|
@@ -153,12 +153,16 @@ export function sweepTelemetryDir(dir, {
|
|
|
153
153
|
return { scanned: 0, removed: 0 };
|
|
154
154
|
}
|
|
155
155
|
const cutoff = now() - maxAgeMs;
|
|
156
|
+
// BUG-C22-11: eligible count is computed from the full name listing (bounded
|
|
157
|
+
// by the single readdir, no per-name stat) so the count cap is enforceable
|
|
158
|
+
// even though maxFiles > maxEntries — capping `scanned` first made
|
|
159
|
+
// `entries.length` an under-count and `overflow` was always 0.
|
|
160
|
+
const eligibleNames = names.filter((name) => name.endsWith('.jsonl'));
|
|
156
161
|
const entries = [];
|
|
157
162
|
let scanned = 0;
|
|
158
|
-
for (const name of
|
|
163
|
+
for (const name of eligibleNames) {
|
|
159
164
|
if (scanned >= maxEntries) break;
|
|
160
165
|
scanned += 1;
|
|
161
|
-
if (!name.endsWith('.jsonl')) continue;
|
|
162
166
|
try {
|
|
163
167
|
const stats = fs.statSync(path.join(dir, name));
|
|
164
168
|
if (stats.isFile()) entries.push({ name, mtimeMs: stats.mtimeMs });
|
|
@@ -168,7 +172,7 @@ export function sweepTelemetryDir(dir, {
|
|
|
168
172
|
// Overflow counts eligible FILES only — foreign entries (non-matching names,
|
|
169
173
|
// dirs) inflate `names.length` and would evict real entries while the dir is
|
|
170
174
|
// actually under the cap.
|
|
171
|
-
const overflow = Math.max(0,
|
|
175
|
+
const overflow = Math.max(0, eligibleNames.length - maxFiles);
|
|
172
176
|
let removed = 0;
|
|
173
177
|
for (const entry of entries) {
|
|
174
178
|
if (removed >= maxRemovals) break;
|
|
@@ -410,16 +410,27 @@ export async function sweepTeeCache(dir, {
|
|
|
410
410
|
maxEntries = TEE_SWEEP_MAX_ENTRIES,
|
|
411
411
|
maxRemovals = TEE_SWEEP_MAX_REMOVALS,
|
|
412
412
|
} = {}) {
|
|
413
|
-
let
|
|
413
|
+
let dirents;
|
|
414
414
|
try {
|
|
415
|
-
|
|
415
|
+
dirents = await fs.readdir(dir, { withFileTypes: true });
|
|
416
416
|
} catch {
|
|
417
417
|
return { sampled: true, scanned: 0, removed: 0 };
|
|
418
418
|
}
|
|
419
|
+
// Eligible-file count comes from the dirents (one bounded readdir, no per-name
|
|
420
|
+
// stat) so the count cap is enforceable even when maxFiles > maxEntries —
|
|
421
|
+
// BUG-C22-10: capping `scanned` at maxEntries made `entries.length` an
|
|
422
|
+
// under-count and `overflow` was always 0. Expected drain: each sampled sweep
|
|
423
|
+
// removes up to maxRemovals, so a backlog of B files converges after
|
|
424
|
+
// ~ceil(B/maxRemovals) sweeps (~16x that many writes at the default 1/16
|
|
425
|
+
// sampling); the 7d age cutoff drains the rest even without new writes.
|
|
426
|
+
const eligibleNames = [];
|
|
427
|
+
for (const dirent of dirents) {
|
|
428
|
+
if (dirent.isFile()) eligibleNames.push(dirent.name);
|
|
429
|
+
}
|
|
419
430
|
const cutoff = now() - maxAgeMs;
|
|
420
431
|
const entries = [];
|
|
421
432
|
let scanned = 0;
|
|
422
|
-
for (const name of
|
|
433
|
+
for (const name of eligibleNames) {
|
|
423
434
|
if (scanned >= maxEntries) break;
|
|
424
435
|
scanned += 1;
|
|
425
436
|
try {
|
|
@@ -429,9 +440,9 @@ export async function sweepTeeCache(dir, {
|
|
|
429
440
|
}
|
|
430
441
|
entries.sort((a, b) => a.mtimeMs - b.mtimeMs); // oldest first
|
|
431
442
|
// Overflow counts eligible FILES only — foreign entries (non-matching names,
|
|
432
|
-
// dirs) inflate
|
|
443
|
+
// dirs) inflate the dir listing and would evict real entries while the dir is
|
|
433
444
|
// actually under the cap.
|
|
434
|
-
const overflow = Math.max(0,
|
|
445
|
+
const overflow = Math.max(0, eligibleNames.length - maxFiles);
|
|
435
446
|
let removed = 0;
|
|
436
447
|
for (const entry of entries) {
|
|
437
448
|
if (removed >= maxRemovals) break;
|
|
@@ -108,7 +108,7 @@ async function main() {
|
|
|
108
108
|
// (writeThresholdCompactPlan) re-read from disk, so they build on the zeroed state and
|
|
109
109
|
// keep their own stats increments intact. Session-scoped: sibling sessions keep their
|
|
110
110
|
// own records.
|
|
111
|
-
await writeCompactPressureState(projectRoot, resetCompactPressureState(pressureState, sessionConfig), sessionConfig);
|
|
111
|
+
await writeCompactPressureState(projectRoot, await resetCompactPressureState(pressureState, sessionConfig), sessionConfig);
|
|
112
112
|
const anchorLines = dynamicLines.filter((line) => (
|
|
113
113
|
line.startsWith('- Recent routed lane:')
|
|
114
114
|
|| line.startsWith('- Current approach:')
|
|
@@ -124,7 +124,7 @@ async function main() {
|
|
|
124
124
|
|| line.startsWith('- Recent delegation hint:')
|
|
125
125
|
));
|
|
126
126
|
const maxTokens = Math.max(160, Math.min(320, Number(config.memory?.maxInjectionTokens) || 320));
|
|
127
|
-
const thresholdPlan = buildThresholdCompactPlan({
|
|
127
|
+
const thresholdPlan = await buildThresholdCompactPlan({
|
|
128
128
|
state: pressureState,
|
|
129
129
|
config,
|
|
130
130
|
routeState: state,
|
|
@@ -683,6 +683,9 @@ function isDirectRun() {
|
|
|
683
683
|
selfUrlPath = null;
|
|
684
684
|
}
|
|
685
685
|
if (selfUrlPath && selfUrlPath === selfPath) return true;
|
|
686
|
+
// BUG-C22-01: skip fs entirely for non-matching importers — a stalled mount
|
|
687
|
+
// must not park the event loop during `await import` inside an armed deadline.
|
|
688
|
+
if (selfUrlPath && path.basename(selfUrlPath) !== path.basename(selfPath)) return false;
|
|
686
689
|
try {
|
|
687
690
|
return fsSync.realpathSync(selfUrlPath) === fsSync.realpathSync(selfPath);
|
|
688
691
|
} catch {
|
|
@@ -175,8 +175,15 @@ export async function withFileLock(filePath, fn, { staleMs = LOCK_STALE_MS, maxW
|
|
|
175
175
|
continue; // the slot is free now — retry immediately
|
|
176
176
|
}
|
|
177
177
|
}
|
|
178
|
-
} catch {
|
|
179
|
-
|
|
178
|
+
} catch (statError) {
|
|
179
|
+
// BUG-C22-16: a persistent stat error (EPERM/ENOTDIR/EIO on a failing
|
|
180
|
+
// mount, or ELOOP/ENOENT on a dangling symlink where mkdir still reports
|
|
181
|
+
// EEXIST) must not busy-spin — a bare `continue` skipped both the
|
|
182
|
+
// maxWait break and the backoff sleep, looping mkdir→stat→throw forever.
|
|
183
|
+
if (Date.now() - startedAt >= maxWaitMs) break; // fail open — run unlocked
|
|
184
|
+
if (statError?.code === 'ENOENT') continue; // lock vanished — retry immediately
|
|
185
|
+
await sleep(lockBackoffDelayMs());
|
|
186
|
+
continue;
|
|
180
187
|
}
|
|
181
188
|
|
|
182
189
|
if (Date.now() - startedAt >= maxWaitMs) break; // fail open — run unlocked
|