@ngockhoale/ukit 3.0.9 → 3.0.11
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 +15 -1
- package/bin/ukit +5 -9
- package/package.json +1 -1
- package/scripts/bench/memory-baseline.mjs +1 -1
- package/scripts/bench/memory-bench.mjs +1 -1
- package/scripts/bench/memory-canary.mjs +12 -2
- package/scripts/bench/recorder-overhead.mjs +1 -1
- package/scripts/bench/sqlite-spike.mjs +1 -1
- package/scripts/measure-decision-gateway.mjs +1 -1
- package/src/cli/commands/memory.js +3 -0
- package/src/cli/deadline.js +19 -0
- package/src/core/agentRuntime/eventStore.js +38 -10
- package/src/core/agentRuntime/supervisor.js +41 -9
- package/src/core/agentRuntime/vmEngine.js +33 -2
- package/src/core/codeintel/freshness.js +13 -9
- package/src/core/codeintel/retriever.js +25 -20
- package/src/core/fileOps.js +19 -4
- package/src/core/memory/memoryFlags.js +14 -6
- package/src/core/metadata.js +111 -81
- package/src/core/token/index.js +69 -28
- package/src/index/buildIndex.js +7 -0
- package/src/index/impactContext.js +14 -5
- package/src/render/instructionRenderer.js +71 -19
- package/template_project/.claude/hooks/auto-prune-bash.sh +60 -83
- package/template_project/.claude/hooks/block-dangerous.mjs +42 -11
- package/template_project/.claude/skills/docs-manager/init-project-docs.sh +59 -16
- package/template_project/.claude/skills/docx/scripts/document.py +31 -8
- package/template_project/.claude/skills/frontend-vue/composables/indexDBStore.js +26 -5
- package/template_project/.claude/skills/frontend-vue/composables/useRequest.js +24 -12
- package/template_project/.claude/skills/frontend-vue/composables/useSession.js +16 -11
- package/template_project/.claude/skills/frontend-vue/composables/useWebSocket.js +25 -2
- package/template_project/.claude/skills/pptx/scripts/rearrange.py +11 -2
- package/template_project/.claude/skills/root-cause-tracing/find-polluter.sh +20 -5
- package/template_project/.claude/skills/webapp-testing/scripts/with_server.py +37 -6
- package/template_project/.claude/ukit/index/cache-utils.mjs +25 -19
- package/template_project/.claude/ukit/index/impact-context.mjs +21 -13
- package/template_project/.claude/ukit/index/lib/index-core.mjs +96 -14
- package/template_project/.claude/ukit/index/query-index.mjs +13 -11
- package/template_project/.claude/ukit/index/resolve-context.mjs +12 -11
- package/template_project/.claude/ukit/index/route-task.mjs +44 -25
- package/template_project/.claude/ukit/index/triage.mjs +12 -11
- package/template_project/.claude/ukit/index/verify-context.mjs +12 -11
- package/template_project/.claude/ukit/runtime/async-lock.mjs +33 -0
- package/template_project/.claude/ukit/runtime/output-compression.mjs +9 -2
- package/template_project/.claude/ukit/runtime/resumable-run.mjs +18 -2
- package/template_project/.claude/ukit/runtime/token-utils.mjs +60 -18
|
@@ -42,6 +42,12 @@ export function useWebSocket(url, options = {}) {
|
|
|
42
42
|
const RETRY_DELAY = options.retryDelay || 3000
|
|
43
43
|
const connectionStatus = ref('initial') // 'initial', 'connecting', 'connected', 'disconnected', 'error'
|
|
44
44
|
const lastError = ref(null)
|
|
45
|
+
// C80-12/13: pending retry timer + disposed flag. Both onerror and onclose
|
|
46
|
+
// call retryConnection for a single failure; without dedup that fires two
|
|
47
|
+
// parallel reconnect loops. Without storing the timer, a pending retry
|
|
48
|
+
// survives unmount and reconnects a zombie socket.
|
|
49
|
+
let retryTimer = null
|
|
50
|
+
let disposed = false
|
|
45
51
|
|
|
46
52
|
// Debug logging function
|
|
47
53
|
const debugLog = (type, message, data = null) => {
|
|
@@ -50,6 +56,7 @@ export function useWebSocket(url, options = {}) {
|
|
|
50
56
|
}
|
|
51
57
|
|
|
52
58
|
const connect = () => {
|
|
59
|
+
disposed = false
|
|
53
60
|
if (!url) {
|
|
54
61
|
debugLog('ERROR', 'WebSocket URL is required')
|
|
55
62
|
error.value = 'WebSocket URL is required'
|
|
@@ -61,7 +68,7 @@ export function useWebSocket(url, options = {}) {
|
|
|
61
68
|
|
|
62
69
|
try {
|
|
63
70
|
socket.value = new WebSocket(url)
|
|
64
|
-
|
|
71
|
+
|
|
65
72
|
socket.value.onopen = () => {
|
|
66
73
|
connectionStatus.value = 'connected'
|
|
67
74
|
debugLog('SUCCESS', 'WebSocket connection opened successfully')
|
|
@@ -106,10 +113,16 @@ export function useWebSocket(url, options = {}) {
|
|
|
106
113
|
}
|
|
107
114
|
|
|
108
115
|
const retryConnection = () => {
|
|
116
|
+
if (disposed || retryTimer !== null) {
|
|
117
|
+
return
|
|
118
|
+
}
|
|
109
119
|
if (retryCount.value < MAX_RETRIES) {
|
|
110
120
|
retryCount.value++
|
|
111
121
|
debugLog('INFO', `Retrying connection (${retryCount.value}/${MAX_RETRIES})...`)
|
|
112
|
-
setTimeout(
|
|
122
|
+
retryTimer = setTimeout(() => {
|
|
123
|
+
retryTimer = null
|
|
124
|
+
connect()
|
|
125
|
+
}, RETRY_DELAY)
|
|
113
126
|
} else {
|
|
114
127
|
connectionStatus.value = 'error'
|
|
115
128
|
debugLog('ERROR', 'Max retry attempts reached')
|
|
@@ -130,6 +143,11 @@ export function useWebSocket(url, options = {}) {
|
|
|
130
143
|
|
|
131
144
|
onMounted(connect)
|
|
132
145
|
onUnmounted(() => {
|
|
146
|
+
disposed = true
|
|
147
|
+
if (retryTimer !== null) {
|
|
148
|
+
clearTimeout(retryTimer)
|
|
149
|
+
retryTimer = null
|
|
150
|
+
}
|
|
133
151
|
if (socket.value) {
|
|
134
152
|
debugLog('INFO', 'Disconnecting WebSocket')
|
|
135
153
|
socket.value.close()
|
|
@@ -143,6 +161,11 @@ export function useWebSocket(url, options = {}) {
|
|
|
143
161
|
return {
|
|
144
162
|
connect,
|
|
145
163
|
disconnect: () => {
|
|
164
|
+
disposed = true
|
|
165
|
+
if (retryTimer !== null) {
|
|
166
|
+
clearTimeout(retryTimer)
|
|
167
|
+
retryTimer = null
|
|
168
|
+
}
|
|
146
169
|
if (socket.value) {
|
|
147
170
|
debugLog('INFO', 'Disconnecting WebSocket')
|
|
148
171
|
socket.value.close()
|
|
@@ -155,8 +155,17 @@ def rearrange_presentation(template_path, output_path, slide_sequence):
|
|
|
155
155
|
output_path: Path for output PPTX file
|
|
156
156
|
slide_sequence: List of slide indices (0-based) to include
|
|
157
157
|
"""
|
|
158
|
-
# Copy template to preserve dimensions and theme
|
|
159
|
-
|
|
158
|
+
# Copy template to preserve dimensions and theme.
|
|
159
|
+
# C80-21: compare RESOLVED paths, not raw strings — "./a.pptx" vs "a.pptx"
|
|
160
|
+
# are the same file and shutil.copy2 onto itself raises SameFileError.
|
|
161
|
+
same_file = os.path.abspath(template_path) == os.path.abspath(output_path)
|
|
162
|
+
if not same_file:
|
|
163
|
+
if os.path.exists(output_path) and os.path.exists(template_path):
|
|
164
|
+
try:
|
|
165
|
+
same_file = os.path.samefile(template_path, output_path)
|
|
166
|
+
except OSError:
|
|
167
|
+
same_file = False
|
|
168
|
+
if not same_file:
|
|
160
169
|
shutil.copy2(template_path, output_path)
|
|
161
170
|
prs = Presentation(output_path)
|
|
162
171
|
else:
|
|
@@ -18,15 +18,30 @@ echo "🔍 Searching for test that creates: $POLLUTION_CHECK"
|
|
|
18
18
|
echo "Test pattern: $TEST_PATTERN"
|
|
19
19
|
echo ""
|
|
20
20
|
|
|
21
|
-
# Get list of test files
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
# Get list of test files.
|
|
22
|
+
# C80-22: find -path has no '**' semantics — collapse runs of '*' so
|
|
23
|
+
# 'src/**/*.test.ts' matches files at ANY depth (find's '*' already crosses
|
|
24
|
+
# '/'), and expand '/**/' to also cover zero directory levels.
|
|
25
|
+
FIND_PATTERN="${TEST_PATTERN//\*\*\/\*/*}"
|
|
26
|
+
FIND_PATTERN="${FIND_PATTERN//\*\*\/\//*/}"
|
|
27
|
+
FIND_PATTERN="${FIND_PATTERN//\/**\//}"
|
|
28
|
+
FIND_PATTERN="${FIND_PATTERN//\*\*/*}"
|
|
29
|
+
# find prints './'-prefixed paths; a pattern must match the WHOLE path.
|
|
30
|
+
case "$FIND_PATTERN" in
|
|
31
|
+
./*|/*|\**) ;;
|
|
32
|
+
*) FIND_PATTERN="./$FIND_PATTERN" ;;
|
|
33
|
+
esac
|
|
34
|
+
TEST_FILES=$(find . -path "$FIND_PATTERN" | sort)
|
|
35
|
+
TOTAL=$(printf '%s\n' "$TEST_FILES" | grep -c . | tr -d ' ')
|
|
24
36
|
|
|
25
37
|
echo "Found $TOTAL test files"
|
|
26
38
|
echo ""
|
|
27
39
|
|
|
28
40
|
COUNT=0
|
|
29
|
-
for
|
|
41
|
+
# C80-22: iterate line-wise — `for x in $files` splits on whitespace and
|
|
42
|
+
# breaks test filenames containing spaces.
|
|
43
|
+
while IFS= read -r TEST_FILE; do
|
|
44
|
+
[ -z "$TEST_FILE" ] && continue
|
|
30
45
|
COUNT=$((COUNT + 1))
|
|
31
46
|
|
|
32
47
|
# Skip if pollution already exists
|
|
@@ -56,7 +71,7 @@ for TEST_FILE in $TEST_FILES; do
|
|
|
56
71
|
echo " cat $TEST_FILE # Review test code"
|
|
57
72
|
exit 1
|
|
58
73
|
fi
|
|
59
|
-
done
|
|
74
|
+
done <<< "$TEST_FILES"
|
|
60
75
|
|
|
61
76
|
echo ""
|
|
62
77
|
echo "✅ No polluter found - all tests clean!"
|
|
@@ -19,6 +19,9 @@ import socket
|
|
|
19
19
|
import time
|
|
20
20
|
import sys
|
|
21
21
|
import argparse
|
|
22
|
+
import os
|
|
23
|
+
import signal
|
|
24
|
+
|
|
22
25
|
|
|
23
26
|
def is_server_ready(port, timeout=30):
|
|
24
27
|
"""Wait for server to be ready by polling the port."""
|
|
@@ -65,12 +68,19 @@ def main():
|
|
|
65
68
|
for i, server in enumerate(servers):
|
|
66
69
|
print(f"Starting server {i+1}/{len(servers)}: {server['cmd']}")
|
|
67
70
|
|
|
68
|
-
# Use shell=True to support commands with cd and
|
|
71
|
+
# Use shell=True to support commands with cd and &&.
|
|
72
|
+
# C80-17: stdout/stderr go to the inherited terminal, NOT PIPE —
|
|
73
|
+
# a piped-but-undrained server deadlocks once it logs past the OS
|
|
74
|
+
# pipe buffer (~64KB) and is_server_ready then reports a bogus
|
|
75
|
+
# "failed to start" timeout.
|
|
76
|
+
# C80-18: start_new_session puts the shell + all its children in a
|
|
77
|
+
# dedicated process group so cleanup can signal the WHOLE tree —
|
|
78
|
+
# terminate() alone kills only the /bin/sh wrapper and orphans the
|
|
79
|
+
# real server (port stays bound for the next run).
|
|
69
80
|
process = subprocess.Popen(
|
|
70
81
|
server['cmd'],
|
|
71
82
|
shell=True,
|
|
72
|
-
|
|
73
|
-
stderr=subprocess.PIPE
|
|
83
|
+
start_new_session=(os.name == 'posix'),
|
|
74
84
|
)
|
|
75
85
|
server_processes.append(process)
|
|
76
86
|
|
|
@@ -93,14 +103,35 @@ def main():
|
|
|
93
103
|
print(f"\nStopping {len(server_processes)} server(s)...")
|
|
94
104
|
for i, process in enumerate(server_processes):
|
|
95
105
|
try:
|
|
96
|
-
process
|
|
106
|
+
_terminate_group(process)
|
|
97
107
|
process.wait(timeout=5)
|
|
98
108
|
except subprocess.TimeoutExpired:
|
|
99
|
-
process
|
|
109
|
+
_kill_group(process)
|
|
100
110
|
process.wait()
|
|
101
111
|
print(f"Server {i+1} stopped")
|
|
102
112
|
print("All servers stopped")
|
|
103
113
|
|
|
104
114
|
|
|
105
115
|
if __name__ == '__main__':
|
|
106
|
-
main()
|
|
116
|
+
main()
|
|
117
|
+
|
|
118
|
+
def _terminate_group(process):
|
|
119
|
+
"""SIGTERM the whole process group on POSIX; fall back to terminate()."""
|
|
120
|
+
if os.name == 'posix':
|
|
121
|
+
try:
|
|
122
|
+
os.killpg(process.pid, signal.SIGTERM)
|
|
123
|
+
return
|
|
124
|
+
except (ProcessLookupError, PermissionError):
|
|
125
|
+
return # group already gone
|
|
126
|
+
process.terminate()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _kill_group(process):
|
|
130
|
+
"""SIGKILL the whole process group on POSIX; fall back to kill()."""
|
|
131
|
+
if os.name == 'posix':
|
|
132
|
+
try:
|
|
133
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
134
|
+
return
|
|
135
|
+
except (ProcessLookupError, PermissionError):
|
|
136
|
+
return
|
|
137
|
+
process.kill()
|
|
@@ -60,25 +60,31 @@ export async function readRecentCacheEntry(
|
|
|
60
60
|
...entry,
|
|
61
61
|
updatedAt: Date.now(),
|
|
62
62
|
};
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
63
|
+
// A full/unwritable cache must never reject the read — degrade to the
|
|
64
|
+
// in-memory touched entry (same contract as token-utils readPromptCacheEntry).
|
|
65
|
+
try {
|
|
66
|
+
return await withFileLock(cachePath, async () => {
|
|
67
|
+
const locked = await readRecentCache(cachePath, { maxEntries });
|
|
68
|
+
const lockedIndex = locked.entries.findIndex((lockedEntry) => lockedEntry.requestKey === requestKey);
|
|
69
|
+
if (lockedIndex < 0) {
|
|
70
|
+
return touchedEntry;
|
|
71
|
+
}
|
|
72
|
+
const lockedEntry = {
|
|
73
|
+
...locked.entries[lockedIndex],
|
|
74
|
+
updatedAt: touchedEntry.updatedAt,
|
|
75
|
+
};
|
|
76
|
+
const entries = [
|
|
77
|
+
lockedEntry,
|
|
78
|
+
...locked.entries.slice(0, lockedIndex),
|
|
79
|
+
...locked.entries.slice(lockedIndex + 1),
|
|
80
|
+
].slice(0, maxEntries);
|
|
81
|
+
const document = buildCacheDocument(entries, { maxEntries });
|
|
82
|
+
await writeCacheJson(cachePath, document);
|
|
83
|
+
return document.entries[0] ?? lockedEntry;
|
|
84
|
+
});
|
|
85
|
+
} catch {
|
|
86
|
+
return touchedEntry;
|
|
87
|
+
}
|
|
82
88
|
}
|
|
83
89
|
|
|
84
90
|
export async function writeRecentCacheEntry(
|
|
@@ -59,19 +59,22 @@ async function main() {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
async function ensureFreshIndex(rootDir) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
? true
|
|
65
|
-
: await isIndexStale({
|
|
66
|
-
rootDir,
|
|
67
|
-
maxAgeMs: DEFAULT_INDEX_CACHE_MAX_AGE_MS,
|
|
68
|
-
now: Date.now(),
|
|
69
|
-
generatedAtMs,
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
if (stale) {
|
|
73
|
-
await buildCodeIndex({ rootDir });
|
|
62
|
+
if (typeof indexCore.ensureIndexFresh === 'function') {
|
|
63
|
+
return indexCore.ensureIndexFresh({ rootDir });
|
|
74
64
|
}
|
|
65
|
+
const lastRefreshMs = await getIndexArtifactGeneratedAt({ rootDir });
|
|
66
|
+
const stale = lastRefreshMs === null
|
|
67
|
+
? true
|
|
68
|
+
: typeof indexCore.isIndexStale === 'function'
|
|
69
|
+
? await indexCore.isIndexStale({
|
|
70
|
+
rootDir,
|
|
71
|
+
maxAgeMs: DEFAULT_INDEX_CACHE_MAX_AGE_MS,
|
|
72
|
+
now: Date.now(),
|
|
73
|
+
generatedAtMs: lastRefreshMs,
|
|
74
|
+
})
|
|
75
|
+
: (Date.now() - lastRefreshMs) > DEFAULT_INDEX_CACHE_MAX_AGE_MS;
|
|
76
|
+
if (!stale) return;
|
|
77
|
+
await buildCodeIndex({ rootDir });
|
|
75
78
|
}
|
|
76
79
|
|
|
77
80
|
function printCompact(impact) {
|
|
@@ -119,4 +122,9 @@ function getRootDir(args) {
|
|
|
119
122
|
return path.resolve(explicitRoot || process.cwd());
|
|
120
123
|
}
|
|
121
124
|
|
|
122
|
-
|
|
125
|
+
try {
|
|
126
|
+
await main();
|
|
127
|
+
} catch (error) {
|
|
128
|
+
console.error(error && error.message ? error.message : error);
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import crypto from 'node:crypto';
|
|
4
4
|
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { withFileLock } from '../../runtime/token-utils.mjs';
|
|
5
6
|
|
|
6
7
|
// One wall-clock ceiling for the WHOLE discovery phase: both `git ls-files`
|
|
7
8
|
// enumerations AND the non-git fallback walk share this single budget. Every
|
|
@@ -464,6 +465,20 @@ export async function buildCodeIndex({ rootDir = process.cwd(), changedFiles = n
|
|
|
464
465
|
// artifact-graph phases above) must not replace any artifact either, so the
|
|
465
466
|
// whole write block sits behind this gate.
|
|
466
467
|
assertNotAborted(signal, 'publish');
|
|
468
|
+
// Invalidate the process-lifetime artifact caches BEFORE the first write:
|
|
469
|
+
// clearing only after all writes leaves a window where queued/concurrent
|
|
470
|
+
// reads in this process keep serving the pre-rebuild generation (C80-08).
|
|
471
|
+
const preservedRuntimeCaches = canReuseFilesArtifact
|
|
472
|
+
&& canReuseAllParsedArtifacts
|
|
473
|
+
&& canReuseTestsMap
|
|
474
|
+
&& canReuseHotspots
|
|
475
|
+
&& canReuseArchetypes
|
|
476
|
+
&& canReuseRelations
|
|
477
|
+
&& canReuseAnalogs;
|
|
478
|
+
if (!preservedRuntimeCaches) {
|
|
479
|
+
clearIndexArtifactCache(absoluteRoot);
|
|
480
|
+
RELATED_TEST_LOOKUP_CACHE.delete(absoluteRoot);
|
|
481
|
+
}
|
|
467
482
|
await fs.mkdir(indexDir, { recursive: true });
|
|
468
483
|
if (!canReuseFilesArtifact) {
|
|
469
484
|
await writeArtifact(absoluteRoot, INDEX_ARTIFACTS.files, { schemaVersion: INDEX_SCHEMA_VERSION, generatedAt, items: fileRecords });
|
|
@@ -502,17 +517,6 @@ export async function buildCodeIndex({ rootDir = process.cwd(), changedFiles = n
|
|
|
502
517
|
[INDEX_ARTIFACTS.relations]: relations.length,
|
|
503
518
|
},
|
|
504
519
|
});
|
|
505
|
-
const preservedRuntimeCaches = canReuseFilesArtifact
|
|
506
|
-
&& canReuseAllParsedArtifacts
|
|
507
|
-
&& canReuseTestsMap
|
|
508
|
-
&& canReuseHotspots
|
|
509
|
-
&& canReuseArchetypes
|
|
510
|
-
&& canReuseRelations
|
|
511
|
-
&& canReuseAnalogs;
|
|
512
|
-
if (!preservedRuntimeCaches) {
|
|
513
|
-
clearIndexArtifactCache(absoluteRoot);
|
|
514
|
-
RELATED_TEST_LOOKUP_CACHE.delete(absoluteRoot);
|
|
515
|
-
}
|
|
516
520
|
|
|
517
521
|
return {
|
|
518
522
|
indexDir,
|
|
@@ -694,6 +698,13 @@ async function resolveChangedHintUpdates(rootDir, changedPaths, previousRecordsB
|
|
|
694
698
|
if (!isSafeContainedRelative(rootDir, relativePath)) {
|
|
695
699
|
return null;
|
|
696
700
|
}
|
|
701
|
+
// Hints under directories discovery never indexes are no-ops, not trust
|
|
702
|
+
// failures: `node_modules/`, `.cache/` and friends bypassed this lane and
|
|
703
|
+
// entered the index as phantom rows that kept the index perpetually stale.
|
|
704
|
+
// Same predicate as collectGitTrackedFiles.
|
|
705
|
+
if (relativePath.split('/').some((segment) => EXCLUDED_DIR_NAMES.has(segment))) {
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
697
708
|
|
|
698
709
|
const absolutePath = path.join(rootDir, relativePath);
|
|
699
710
|
let stat = null;
|
|
@@ -4319,8 +4330,8 @@ export async function resolveImpactContext({
|
|
|
4319
4330
|
loadRelatedTestArtifacts({ rootDir: absoluteRoot }),
|
|
4320
4331
|
]);
|
|
4321
4332
|
|
|
4322
|
-
const callItems = callsArtifact
|
|
4323
|
-
const importItems = importsArtifact
|
|
4333
|
+
const callItems = safeItems(callsArtifact);
|
|
4334
|
+
const importItems = safeItems(importsArtifact);
|
|
4324
4335
|
const risk = classifyImpactRisk(normalizedChangedFiles);
|
|
4325
4336
|
const mirrorCounterparts = findMirrorCounterparts(normalizedChangedFiles).slice(0, budget.maxMirrors);
|
|
4326
4337
|
const changedFileSet = new Set(normalizedChangedFiles);
|
|
@@ -4358,7 +4369,7 @@ export async function resolveImpactContext({
|
|
|
4358
4369
|
// through the same machinery the index builder uses; relative specifiers keep
|
|
4359
4370
|
// the candidate-list match so a changed file absent from files.json (e.g. a
|
|
4360
4371
|
// brand-new file) still counts.
|
|
4361
|
-
const indexedFileSet = new Set((filesArtifact
|
|
4372
|
+
const indexedFileSet = new Set(safeItems(filesArtifact).map((item) => item.filePath));
|
|
4362
4373
|
const importAliasContext = importsNeedAliasContext(importItems)
|
|
4363
4374
|
? await loadImportAliasContext({ rootDir: absoluteRoot }).catch(() => null)
|
|
4364
4375
|
: null;
|
|
@@ -4528,3 +4539,74 @@ export async function isIndexStale({
|
|
|
4528
4539
|
const { stale } = await evaluateIndexStaleness({ rootDir, maxAgeMs, now, generatedAtMs, enumerateForSnapshot: false, signal, deadlineMs });
|
|
4529
4540
|
return stale;
|
|
4530
4541
|
}
|
|
4542
|
+
|
|
4543
|
+
/**
|
|
4544
|
+
* Shared freshness gate for all index CLIs/hooks. The stale check + rebuild runs
|
|
4545
|
+
* inside a cross-process file lock (withAsyncLock under withFileLock): without
|
|
4546
|
+
* it, two processes that both observe a stale index enumerate the repository
|
|
4547
|
+
* twice and interleave writeArtifact renames, so a reader starting mid-race can
|
|
4548
|
+
* consume files.json from build A and symbols.json from build B. Inside the
|
|
4549
|
+
* lock the staleness check re-runs, so the second contender becomes a no-op.
|
|
4550
|
+
* Fail-closed lock policy: if the lock cannot be acquired (an extreme hold), we
|
|
4551
|
+
* return the prior timestamp and the caller serves the existing index rather
|
|
4552
|
+
* than building unlocked.
|
|
4553
|
+
*/
|
|
4554
|
+
export async function ensureIndexFresh({
|
|
4555
|
+
rootDir = process.cwd(),
|
|
4556
|
+
maxAgeMs = DEFAULT_INDEX_CACHE_MAX_AGE_MS,
|
|
4557
|
+
logPrefix = null,
|
|
4558
|
+
signal = null,
|
|
4559
|
+
deadlineMs = null,
|
|
4560
|
+
discoverySnapshot = null,
|
|
4561
|
+
} = {}) {
|
|
4562
|
+
const absoluteRoot = path.resolve(rootDir);
|
|
4563
|
+
const discoveryOptions = {
|
|
4564
|
+
...(signal ? { signal } : {}),
|
|
4565
|
+
...(Number.isFinite(deadlineMs) && deadlineMs > 0 ? { deadlineMs } : {}),
|
|
4566
|
+
};
|
|
4567
|
+
const lastRefreshMs = await getIndexArtifactGeneratedAt({ rootDir: absoluteRoot });
|
|
4568
|
+
const stale = lastRefreshMs === null
|
|
4569
|
+
|| (await isIndexStale({
|
|
4570
|
+
rootDir: absoluteRoot,
|
|
4571
|
+
maxAgeMs,
|
|
4572
|
+
now: Date.now(),
|
|
4573
|
+
generatedAtMs: lastRefreshMs,
|
|
4574
|
+
...discoveryOptions,
|
|
4575
|
+
}));
|
|
4576
|
+
|
|
4577
|
+
if (!stale) {
|
|
4578
|
+
return lastRefreshMs;
|
|
4579
|
+
}
|
|
4580
|
+
|
|
4581
|
+
const lockPath = `${getIndexDir(absoluteRoot)}/index-build.lock`;
|
|
4582
|
+
const result = await withFileLock(lockPath, async () => {
|
|
4583
|
+
const lockedRefreshMs = await getIndexArtifactGeneratedAt({ rootDir: absoluteRoot });
|
|
4584
|
+
const lockedStale = lockedRefreshMs === null
|
|
4585
|
+
|| (await isIndexStale({
|
|
4586
|
+
rootDir: absoluteRoot,
|
|
4587
|
+
maxAgeMs,
|
|
4588
|
+
now: Date.now(),
|
|
4589
|
+
generatedAtMs: lockedRefreshMs,
|
|
4590
|
+
...discoveryOptions,
|
|
4591
|
+
}));
|
|
4592
|
+
if (!lockedStale) {
|
|
4593
|
+
return { refreshed: false, generatedAtMs: lockedRefreshMs };
|
|
4594
|
+
}
|
|
4595
|
+
const summary = await buildCodeIndex({ rootDir: absoluteRoot, ...discoveryOptions, discoverySnapshot });
|
|
4596
|
+
return { refreshed: true, summary, generatedAtMs: summary?.generatedAtMs ?? Date.now() };
|
|
4597
|
+
});
|
|
4598
|
+
|
|
4599
|
+
if (result === undefined) {
|
|
4600
|
+
// Lock wait expired — serve the previous generation rather than build
|
|
4601
|
+
// unlocked. The drop is journaled by withFileLock.
|
|
4602
|
+
return lastRefreshMs;
|
|
4603
|
+
}
|
|
4604
|
+
if (result.refreshed && logPrefix) {
|
|
4605
|
+
if (lastRefreshMs !== null) {
|
|
4606
|
+
console.log(`[ukit:${logPrefix}] stale index refreshed (last=${new Date(lastRefreshMs).toISOString()})`);
|
|
4607
|
+
} else {
|
|
4608
|
+
console.log(`[ukit:${logPrefix}] stale index refreshed (no previous cache timestamp)`);
|
|
4609
|
+
}
|
|
4610
|
+
}
|
|
4611
|
+
return result.generatedAtMs ?? lastRefreshMs;
|
|
4612
|
+
}
|
|
@@ -29,7 +29,7 @@ const query = collectPositionalArgs(args, ['--root', '--limit']);
|
|
|
29
29
|
if (!query) {
|
|
30
30
|
console.error('Usage: node .claude/ukit/index/query-index.mjs "<error|symbol|path>"');
|
|
31
31
|
process.exitCode = 1;
|
|
32
|
-
} else {
|
|
32
|
+
} else try {
|
|
33
33
|
const indexGeneratedAtMs = await ensureFreshIndex({ rootDir, logPrefix: 'index:query' });
|
|
34
34
|
const effectiveLimit = Number.isNaN(limit) ? 5 : limit;
|
|
35
35
|
const cachePath = path.join(rootDir, '.claude', 'ukit', 'query-index-cache.json');
|
|
@@ -79,6 +79,11 @@ if (!query) {
|
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
catch (error) {
|
|
83
|
+
console.error(error && error.message ? error.message : error);
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
|
|
82
87
|
async function printOutlineForFile(root, filePath) {
|
|
83
88
|
if (typeof getFileOutline !== 'function') {
|
|
84
89
|
return;
|
|
@@ -106,6 +111,10 @@ async function printOutlineForFile(root, filePath) {
|
|
|
106
111
|
}
|
|
107
112
|
|
|
108
113
|
async function ensureFreshIndex({ rootDir, logPrefix }) {
|
|
114
|
+
if (typeof indexCore.ensureIndexFresh === 'function') {
|
|
115
|
+
return indexCore.ensureIndexFresh({ rootDir, logPrefix });
|
|
116
|
+
}
|
|
117
|
+
// Fallback for stubbed/legacy index-cores (tests inject minimal modules).
|
|
109
118
|
const lastRefreshMs = await getIndexArtifactGeneratedAt({ rootDir });
|
|
110
119
|
const stale = lastRefreshMs === null
|
|
111
120
|
? true
|
|
@@ -117,17 +126,10 @@ async function ensureFreshIndex({ rootDir, logPrefix }) {
|
|
|
117
126
|
generatedAtMs: lastRefreshMs,
|
|
118
127
|
})
|
|
119
128
|
: (Date.now() - lastRefreshMs) > DEFAULT_INDEX_CACHE_MAX_AGE_MS;
|
|
120
|
-
|
|
121
|
-
if (!stale) {
|
|
122
|
-
return lastRefreshMs;
|
|
123
|
-
}
|
|
124
|
-
|
|
129
|
+
if (!stale) return lastRefreshMs;
|
|
125
130
|
const summary = await buildCodeIndex({ rootDir });
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
} else {
|
|
129
|
-
console.log(`[ukit:${logPrefix}] stale index refreshed (no previous cache timestamp)`);
|
|
130
|
-
}
|
|
131
|
+
// Prefer the build's own generatedAtMs; only re-read when the stubbed
|
|
132
|
+
// build didn't report one (keeps generatedAt call count at 1).
|
|
131
133
|
return Number.isFinite(summary?.generatedAtMs)
|
|
132
134
|
? summary.generatedAtMs
|
|
133
135
|
: getIndexArtifactGeneratedAt({ rootDir });
|
|
@@ -31,7 +31,7 @@ const effectiveIntent = deriveHelperIntent({ intent, targetFile });
|
|
|
31
31
|
if (!intent && !targetFile) {
|
|
32
32
|
console.error('Usage: node .claude/ukit/index/resolve-context.mjs "<intent>" [--target <file>] [--type trivial|simple|non-trivial]');
|
|
33
33
|
process.exitCode = 1;
|
|
34
|
-
} else {
|
|
34
|
+
} else try {
|
|
35
35
|
const indexGeneratedAtMs = await ensureFreshIndex({
|
|
36
36
|
rootDir,
|
|
37
37
|
logPrefix: 'context',
|
|
@@ -64,7 +64,15 @@ if (!intent && !targetFile) {
|
|
|
64
64
|
await printContextResult(result, { rootDir, showOutline });
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
catch (error) {
|
|
68
|
+
console.error(error && error.message ? error.message : error);
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
|
|
67
72
|
async function ensureFreshIndex({ rootDir, logPrefix }) {
|
|
73
|
+
if (typeof indexCore.ensureIndexFresh === 'function') {
|
|
74
|
+
return indexCore.ensureIndexFresh({ rootDir, logPrefix });
|
|
75
|
+
}
|
|
68
76
|
const lastRefreshMs = await getIndexArtifactGeneratedAt({ rootDir });
|
|
69
77
|
const stale = lastRefreshMs === null
|
|
70
78
|
? true
|
|
@@ -76,17 +84,10 @@ async function ensureFreshIndex({ rootDir, logPrefix }) {
|
|
|
76
84
|
generatedAtMs: lastRefreshMs,
|
|
77
85
|
})
|
|
78
86
|
: (Date.now() - lastRefreshMs) > DEFAULT_INDEX_CACHE_MAX_AGE_MS;
|
|
79
|
-
|
|
80
|
-
if (!stale) {
|
|
81
|
-
return lastRefreshMs;
|
|
82
|
-
}
|
|
83
|
-
|
|
87
|
+
if (!stale) return lastRefreshMs;
|
|
84
88
|
const summary = await buildCodeIndex({ rootDir });
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
} else {
|
|
88
|
-
console.log(`[ukit:${logPrefix}] stale index refreshed (no previous cache timestamp)`);
|
|
89
|
-
}
|
|
89
|
+
// Prefer the build's own generatedAtMs; only re-read when the stubbed
|
|
90
|
+
// build didn't report one (keeps generatedAt call count at 1).
|
|
90
91
|
return Number.isFinite(summary?.generatedAtMs)
|
|
91
92
|
? summary.generatedAtMs
|
|
92
93
|
: getIndexArtifactGeneratedAt({ rootDir });
|
|
@@ -1599,10 +1599,16 @@ async function main() {
|
|
|
1599
1599
|
}
|
|
1600
1600
|
|
|
1601
1601
|
if (cachedRouteState && canPersistCachedRouteState) {
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1602
|
+
// Advisory hit-count touch — an EACCES/ENOSPC under the lock must never
|
|
1603
|
+
// lose the cached route; skip the touch and keep going.
|
|
1604
|
+
try {
|
|
1605
|
+
await readRecentCacheEntry(routeCachePath, requestKey, {
|
|
1606
|
+
maxEntries: DEFAULT_RECENT_CACHE_MAX_ENTRIES,
|
|
1607
|
+
touch: true,
|
|
1608
|
+
});
|
|
1609
|
+
} catch (error) {
|
|
1610
|
+
console.error(`route-task: state persist degraded — ${error?.message ?? error}`);
|
|
1611
|
+
}
|
|
1606
1612
|
}
|
|
1607
1613
|
|
|
1608
1614
|
if (cachedRouteState) {
|
|
@@ -1646,10 +1652,16 @@ async function main() {
|
|
|
1646
1652
|
};
|
|
1647
1653
|
sharedState.fingerprint = buildRouteStateFingerprint(sharedState);
|
|
1648
1654
|
if (canPersistCachedRouteState) {
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1655
|
+
// Advisory persistence — an EACCES/ENOSPC under the lock must never lose
|
|
1656
|
+
// the printed route; degrade to stderr and print anyway.
|
|
1657
|
+
try {
|
|
1658
|
+
await persistSharedRouteState(sharedStatePath, sharedState);
|
|
1659
|
+
await appendRouteAuditEntry(routeAuditPath, buildRouteAuditEntry({
|
|
1660
|
+
state: sharedState,
|
|
1661
|
+
}));
|
|
1662
|
+
} catch (error) {
|
|
1663
|
+
console.error(`route-task: state persist degraded — ${error?.message ?? error}`);
|
|
1664
|
+
}
|
|
1653
1665
|
}
|
|
1654
1666
|
printRouteState(sharedState);
|
|
1655
1667
|
return;
|
|
@@ -1693,23 +1705,31 @@ async function main() {
|
|
|
1693
1705
|
sessionId,
|
|
1694
1706
|
});
|
|
1695
1707
|
if (canPersistRouteState) {
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1708
|
+
// Advisory persistence — an EACCES/ENOSPC under the lock must never lose
|
|
1709
|
+
// the printed route; degrade to stderr and print anyway.
|
|
1710
|
+
try {
|
|
1711
|
+
await persistSharedRouteState(sharedStatePath, sharedState);
|
|
1712
|
+
await appendRouteAuditEntry(routeAuditPath, buildRouteAuditEntry({
|
|
1713
|
+
route,
|
|
1714
|
+
state: sharedState,
|
|
1715
|
+
}));
|
|
1716
|
+
await writeRecentCacheEntry(routeCachePath, compactRouteCacheState(sharedState), {
|
|
1717
|
+
maxEntries: DEFAULT_RECENT_CACHE_MAX_ENTRIES,
|
|
1718
|
+
});
|
|
1719
|
+
} catch (error) {
|
|
1720
|
+
console.error(`route-task: state persist degraded — ${error?.message ?? error}`);
|
|
1721
|
+
}
|
|
1704
1722
|
}
|
|
1705
1723
|
|
|
1706
1724
|
printRouteState(sharedState);
|
|
1707
1725
|
}
|
|
1708
1726
|
|
|
1709
1727
|
async function ensureFreshIndex({ rootDir, logPrefix, signal = null, deadlineMs = null, discoverySnapshot = null } = {}) {
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1728
|
+
if (typeof indexCore.ensureIndexFresh === 'function') {
|
|
1729
|
+
// Cross-process build lock + staleness re-check live in index-core (C80-07).
|
|
1730
|
+
return indexCore.ensureIndexFresh({ rootDir, logPrefix, signal, deadlineMs, discoverySnapshot });
|
|
1731
|
+
}
|
|
1732
|
+
// Fallback for stubbed/legacy index-cores (tests inject minimal modules).
|
|
1713
1733
|
const discoveryOptions = {
|
|
1714
1734
|
...(signal ? { signal } : {}),
|
|
1715
1735
|
...(Number.isFinite(deadlineMs) && deadlineMs > 0 ? { deadlineMs } : {}),
|
|
@@ -1726,11 +1746,7 @@ async function ensureFreshIndex({ rootDir, logPrefix, signal = null, deadlineMs
|
|
|
1726
1746
|
...discoveryOptions,
|
|
1727
1747
|
})
|
|
1728
1748
|
: (Date.now() - lastRefreshMs) > DEFAULT_INDEX_CACHE_MAX_AGE_MS;
|
|
1729
|
-
|
|
1730
|
-
if (!stale) {
|
|
1731
|
-
return lastRefreshMs;
|
|
1732
|
-
}
|
|
1733
|
-
|
|
1749
|
+
if (!stale) return lastRefreshMs;
|
|
1734
1750
|
const summary = await buildCodeIndex({ rootDir, ...discoveryOptions, discoverySnapshot });
|
|
1735
1751
|
if (lastRefreshMs !== null) {
|
|
1736
1752
|
console.log(`[ukit:${logPrefix}] stale index refreshed (last=${new Date(lastRefreshMs).toISOString()})`);
|
|
@@ -6009,5 +6025,8 @@ function isDirectCliInvocation() {
|
|
|
6009
6025
|
}
|
|
6010
6026
|
|
|
6011
6027
|
if (isDirectCliInvocation()) {
|
|
6012
|
-
|
|
6028
|
+
main().catch((error) => {
|
|
6029
|
+
console.error(`route-task: ${error?.message ?? error}`);
|
|
6030
|
+
process.exitCode = 1;
|
|
6031
|
+
});
|
|
6013
6032
|
}
|