opencode-agent-skill 15.0.0 → 15.1.1
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/README.md +5 -5
- package/docs/V15-MANAGED-RUNTIME.md +18 -0
- package/global-config/agents/verifier.md +6 -2
- package/lib/affected-tests.mjs +58 -8
- package/lib/capability-fabric.mjs +107 -48
- package/lib/code-intelligence/index.mjs +4 -15
- package/lib/code-intelligence/lsp-pool.mjs +694 -0
- package/lib/code-intelligence/lsp-provider.mjs +481 -67
- package/lib/compaction-resume-guard.mjs +327 -0
- package/lib/completion-auditor.mjs +20 -0
- package/lib/context-engine-v11.mjs +20 -7
- package/lib/context-manifest.mjs +38 -21
- package/lib/decision-policy.mjs +40 -2
- package/lib/evidence-store.mjs +121 -31
- package/lib/executable-probe.mjs +69 -0
- package/lib/execution-contract.mjs +29 -16
- package/lib/hierarchical-context.mjs +31 -3
- package/lib/learning-engine.mjs +164 -65
- package/lib/memory-engine.mjs +348 -98
- package/lib/model-config.mjs +86 -7
- package/lib/performance-fabric.mjs +354 -107
- package/lib/permission-policy.mjs +232 -0
- package/lib/pi-rpc-pool.mjs +170 -5
- package/lib/provider-recovery.mjs +51 -0
- package/lib/repo-graph.mjs +38 -5
- package/lib/runtime-events.mjs +31 -10
- package/lib/semantic-index.mjs +67 -20
- package/lib/skill-compiler.mjs +84 -18
- package/lib/subagent-artifacts.mjs +182 -0
- package/lib/task-policy.mjs +80 -7
- package/lib/trajectory.mjs +25 -1
- package/lib/untrusted-output.mjs +106 -0
- package/lib/verification-broker.mjs +32 -27
- package/lib/workspace-fingerprint.mjs +43 -8
- package/lib/workspace-hygiene.mjs +481 -0
- package/package.json +8 -6
- package/pi/extensions/ues-child-runtime.ts +103 -5
- package/pi/extensions/ues.ts +984 -95
- package/scripts/benchmark-lsp-pool.mjs +128 -0
- package/scripts/check-release-consistency.mjs +16 -4
- package/scripts/check-source-integrity.mjs +60 -1
- package/scripts/run-test-suite.mjs +143 -0
- package/scripts/smoke-package-closure.mjs +4 -0
package/lib/evidence-store.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto"
|
|
2
2
|
import { existsSync } from "node:fs"
|
|
3
|
-
import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"
|
|
3
|
+
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises"
|
|
4
4
|
import path from "node:path"
|
|
5
5
|
|
|
6
6
|
const STORE_VERSION = 1
|
|
@@ -12,6 +12,82 @@ const AUTO_GC_EVERY_WRITES = 8
|
|
|
12
12
|
const AUTO_GC_LARGE_ITEM_BYTES = 16 * 1024 * 1024
|
|
13
13
|
const PUT_COUNTS = new Map()
|
|
14
14
|
|
|
15
|
+
const STORE_MUTATION_TAILS = new Map()
|
|
16
|
+
const STORE_LOCK_STALE_MS = 30_000
|
|
17
|
+
const STORE_LOCK_WAIT_MS = 40_000
|
|
18
|
+
|
|
19
|
+
function sleep(ms) {
|
|
20
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function storeLockPath(root) {
|
|
24
|
+
return path.join(evidenceStoreRoot(root), ".mutation.lock")
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function withCrossProcessStoreLock(root, fn) {
|
|
28
|
+
const lockDir = storeLockPath(root)
|
|
29
|
+
await mkdir(path.dirname(lockDir), { recursive: true })
|
|
30
|
+
const deadline = Date.now() + STORE_LOCK_WAIT_MS
|
|
31
|
+
let delayMs = 8
|
|
32
|
+
while (true) {
|
|
33
|
+
try {
|
|
34
|
+
await mkdir(lockDir)
|
|
35
|
+
break
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (error?.code !== "EEXIST") throw error
|
|
38
|
+
const info = await stat(lockDir).catch(() => null)
|
|
39
|
+
if (info && Date.now() - info.mtimeMs > STORE_LOCK_STALE_MS) {
|
|
40
|
+
const confirmed = await stat(lockDir).catch(() => null)
|
|
41
|
+
if (
|
|
42
|
+
confirmed &&
|
|
43
|
+
confirmed.ino === info.ino &&
|
|
44
|
+
confirmed.mtimeMs === info.mtimeMs
|
|
45
|
+
) {
|
|
46
|
+
await rm(lockDir, { recursive: true, force: true }).catch(() => {})
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (Date.now() >= deadline) throw new Error("Timed out waiting for evidence-store mutation lock")
|
|
51
|
+
await sleep(delayMs)
|
|
52
|
+
delayMs = Math.min(100, delayMs * 2)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
return await fn()
|
|
57
|
+
} finally {
|
|
58
|
+
await rm(lockDir, { recursive: true, force: true }).catch(() => {})
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function withStoreMutationLock(root, fn) {
|
|
63
|
+
const key = path.resolve(root)
|
|
64
|
+
const previous = STORE_MUTATION_TAILS.get(key) || Promise.resolve()
|
|
65
|
+
let release
|
|
66
|
+
const barrier = new Promise((resolve) => { release = resolve })
|
|
67
|
+
const tail = previous.catch(() => {}).then(() => barrier)
|
|
68
|
+
STORE_MUTATION_TAILS.set(key, tail)
|
|
69
|
+
|
|
70
|
+
await previous.catch(() => {})
|
|
71
|
+
try {
|
|
72
|
+
return await withCrossProcessStoreLock(root, fn)
|
|
73
|
+
} finally {
|
|
74
|
+
release()
|
|
75
|
+
if (STORE_MUTATION_TAILS.get(key) === tail) STORE_MUTATION_TAILS.delete(key)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function atomicWrite(file, value, encoding) {
|
|
80
|
+
await mkdir(path.dirname(file), { recursive: true })
|
|
81
|
+
const temp = file + "." + process.pid + "." + Date.now() + ".tmp"
|
|
82
|
+
await writeFile(temp, value, encoding)
|
|
83
|
+
try {
|
|
84
|
+
await rename(temp, file)
|
|
85
|
+
} catch (error) {
|
|
86
|
+
await rm(temp, { force: true }).catch(() => {})
|
|
87
|
+
throw error
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
15
91
|
function boundedNumber(value, fallback, min, max) {
|
|
16
92
|
const parsed = Number(value)
|
|
17
93
|
if (!Number.isFinite(parsed)) return fallback
|
|
@@ -104,42 +180,50 @@ export async function putEvidence(root, value, options = {}) {
|
|
|
104
180
|
const encoded = toBytes(value, options)
|
|
105
181
|
const hash = createHash("sha256").update(encoded.bytes).digest("hex")
|
|
106
182
|
const target = evidencePaths(root, hash)
|
|
107
|
-
await mkdir(target.dir, { recursive: true })
|
|
108
183
|
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
184
|
+
const mutation = await withStoreMutationLock(root, async () => {
|
|
185
|
+
await mkdir(target.dir, { recursive: true })
|
|
186
|
+
const now = new Date().toISOString()
|
|
187
|
+
const existing = existsSync(target.meta)
|
|
188
|
+
? JSON.parse(await readFile(target.meta, "utf8").catch(() => "{}"))
|
|
189
|
+
: null
|
|
113
190
|
|
|
114
|
-
|
|
191
|
+
if (!existsSync(target.data)) {
|
|
192
|
+
await atomicWrite(target.data, encoded.bytes)
|
|
193
|
+
}
|
|
115
194
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
195
|
+
const preview = encoded.encoding === "utf8"
|
|
196
|
+
? encoded.bytes.toString("utf8", 0, Math.min(encoded.bytes.length, 600))
|
|
197
|
+
: null
|
|
119
198
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
199
|
+
const metadata = {
|
|
200
|
+
schemaVersion: STORE_VERSION,
|
|
201
|
+
ref: evidenceReference(hash),
|
|
202
|
+
sha256: hash,
|
|
203
|
+
bytes: encoded.bytes.length,
|
|
204
|
+
encoding: encoded.encoding,
|
|
205
|
+
mediaType: encoded.mediaType,
|
|
206
|
+
kind: options.kind || existing?.kind || "tool-output",
|
|
207
|
+
source: options.source || existing?.source || null,
|
|
208
|
+
summary: options.summary || existing?.summary || null,
|
|
209
|
+
createdAt: existing?.createdAt || now,
|
|
210
|
+
lastSeenAt: now,
|
|
211
|
+
preview,
|
|
212
|
+
}
|
|
213
|
+
await atomicWrite(target.meta, JSON.stringify(metadata, null, 2) + "\n", "utf8")
|
|
135
214
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
215
|
+
const writes = (PUT_COUNTS.get(root) || 0) + 1
|
|
216
|
+
PUT_COUNTS.set(root, writes)
|
|
217
|
+
return {
|
|
218
|
+
metadata,
|
|
219
|
+
shouldGc: writes % AUTO_GC_EVERY_WRITES === 0 || encoded.bytes.length >= AUTO_GC_LARGE_ITEM_BYTES,
|
|
220
|
+
}
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
if (mutation.shouldGc) {
|
|
139
224
|
await gcEvidenceStore(root, defaultStoreLimits()).catch(() => null)
|
|
140
225
|
}
|
|
141
|
-
|
|
142
|
-
return metadata
|
|
226
|
+
return mutation.metadata
|
|
143
227
|
}
|
|
144
228
|
|
|
145
229
|
export async function getEvidence(root, ref, options = {}) {
|
|
@@ -275,7 +359,7 @@ export async function evidenceStoreStatus(root = process.cwd()) {
|
|
|
275
359
|
}
|
|
276
360
|
}
|
|
277
361
|
|
|
278
|
-
|
|
362
|
+
async function gcEvidenceStoreUnlocked(root = process.cwd(), options = {}) {
|
|
279
363
|
root = path.resolve(root)
|
|
280
364
|
const files = await metadataFiles(root)
|
|
281
365
|
const defaults = defaultStoreLimits()
|
|
@@ -332,6 +416,12 @@ export async function gcEvidenceStore(root = process.cwd(), options = {}) {
|
|
|
332
416
|
}
|
|
333
417
|
}
|
|
334
418
|
|
|
419
|
+
|
|
420
|
+
export async function gcEvidenceStore(root = process.cwd(), options = {}) {
|
|
421
|
+
root = path.resolve(root)
|
|
422
|
+
return withStoreMutationLock(root, () => gcEvidenceStoreUnlocked(root, options))
|
|
423
|
+
}
|
|
424
|
+
|
|
335
425
|
export async function evidenceExists(root, ref) {
|
|
336
426
|
try {
|
|
337
427
|
const target = evidencePaths(root, normalizeHash(ref))
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { accessSync, constants as fsConstants } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
const DEFAULT_TTL_MS = 60_000
|
|
5
|
+
const commandProbeCache = new Map()
|
|
6
|
+
|
|
7
|
+
function executableFile(target) {
|
|
8
|
+
try {
|
|
9
|
+
accessSync(target, process.platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK)
|
|
10
|
+
return true
|
|
11
|
+
} catch {
|
|
12
|
+
return false
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function probeKey(name) {
|
|
17
|
+
return [
|
|
18
|
+
name,
|
|
19
|
+
String(process.env.PATH || ""),
|
|
20
|
+
process.platform === "win32" ? String(process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD") : "",
|
|
21
|
+
].join("\u0000")
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function commandExists(command, options = {}) {
|
|
25
|
+
if (!command) return false
|
|
26
|
+
const name = String(command).trim()
|
|
27
|
+
if (!name) return false
|
|
28
|
+
|
|
29
|
+
const ttlMs = Math.max(0, Number(options.ttlMs ?? DEFAULT_TTL_MS))
|
|
30
|
+
const key = probeKey(name)
|
|
31
|
+
const now = Date.now()
|
|
32
|
+
const cached = commandProbeCache.get(key)
|
|
33
|
+
if (cached && now - cached.checkedAt < ttlMs) return cached.available
|
|
34
|
+
|
|
35
|
+
const hasPath = path.isAbsolute(name) || name.includes("/") || name.includes("\\")
|
|
36
|
+
let available = false
|
|
37
|
+
if (hasPath) {
|
|
38
|
+
available = executableFile(path.resolve(name))
|
|
39
|
+
} else {
|
|
40
|
+
const dirs = String(process.env.PATH || "")
|
|
41
|
+
.split(path.delimiter)
|
|
42
|
+
.map((item) => item.trim().replace(/^"|"$/g, ""))
|
|
43
|
+
.filter(Boolean)
|
|
44
|
+
const extension = path.extname(name)
|
|
45
|
+
const suffixes = process.platform === "win32" && !extension
|
|
46
|
+
? String(process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD")
|
|
47
|
+
.split(";")
|
|
48
|
+
.map((item) => item.trim())
|
|
49
|
+
.filter(Boolean)
|
|
50
|
+
: [""]
|
|
51
|
+
|
|
52
|
+
outer:
|
|
53
|
+
for (const dir of dirs) {
|
|
54
|
+
for (const suffix of suffixes) {
|
|
55
|
+
if (executableFile(path.join(dir, name + suffix))) {
|
|
56
|
+
available = true
|
|
57
|
+
break outer
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
commandProbeCache.set(key, { available, checkedAt: now })
|
|
64
|
+
return available
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function clearExecutableProbeCache() {
|
|
68
|
+
commandProbeCache.clear()
|
|
69
|
+
}
|
|
@@ -174,26 +174,39 @@ export function detectInheritedDirtyViolations(root, snapshot, approvedPaths = [
|
|
|
174
174
|
}
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
-
export function captureInheritedDirtyState(root) {
|
|
177
|
+
export function captureInheritedDirtyState(root, options = {}) {
|
|
178
178
|
const resolved = path.resolve(root)
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
179
|
+
let statusOutput = ""
|
|
180
|
+
const workspaceState = options.workspaceState || null
|
|
181
|
+
if (
|
|
182
|
+
workspaceState &&
|
|
183
|
+
typeof workspaceState === "object" &&
|
|
184
|
+
workspaceState.root &&
|
|
185
|
+
path.resolve(workspaceState.root) === resolved &&
|
|
186
|
+
typeof workspaceState.statusOutput === "string"
|
|
187
|
+
) {
|
|
188
|
+
statusOutput = workspaceState.statusOutput
|
|
189
|
+
} else {
|
|
190
|
+
const probe = spawnSync("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], {
|
|
191
|
+
cwd: resolved,
|
|
192
|
+
encoding: "utf8",
|
|
193
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
194
|
+
})
|
|
195
|
+
if (probe.status !== 0) {
|
|
196
|
+
return {
|
|
197
|
+
schemaVersion: 1,
|
|
198
|
+
available: false,
|
|
199
|
+
clean: false,
|
|
200
|
+
root: resolved,
|
|
201
|
+
entries: [],
|
|
202
|
+
paths: [],
|
|
203
|
+
error: String(probe.stderr || probe.stdout || "git status failed").trim(),
|
|
204
|
+
}
|
|
193
205
|
}
|
|
206
|
+
statusOutput = String(probe.stdout || "")
|
|
194
207
|
}
|
|
195
208
|
|
|
196
|
-
const tokens =
|
|
209
|
+
const tokens = statusOutput.split("\0").filter(Boolean)
|
|
197
210
|
const entries = []
|
|
198
211
|
for (let index = 0; index < tokens.length; index += 1) {
|
|
199
212
|
const token = tokens[index]
|
|
@@ -3,6 +3,7 @@ import { buildSemanticIndex } from "./semantic-index.mjs"
|
|
|
3
3
|
|
|
4
4
|
const DEFAULT_L0 = 256
|
|
5
5
|
const DEFAULT_L1 = 4000
|
|
6
|
+
let HIERARCHY_RUNTIME_CACHE = new WeakMap()
|
|
6
7
|
const STOP = new Set([
|
|
7
8
|
"this","that","with","from","into","then","than","when","where","what","your","have","will",
|
|
8
9
|
"task","code","file","files","fix","update","change","changes","được","các","cho","với","trong","này","một","những","không","theo",
|
|
@@ -84,6 +85,20 @@ export async function buildContextHierarchy(root = process.cwd(), options = {})
|
|
|
84
85
|
maxFiles: options.maxFiles ?? 6000,
|
|
85
86
|
maxDepth: options.maxDepth ?? 14,
|
|
86
87
|
})
|
|
88
|
+
const l0Limit = Math.max(96, Number(options.l0Chars || DEFAULT_L0))
|
|
89
|
+
const l1Limit = Math.max(512, Number(options.l1Chars || DEFAULT_L1))
|
|
90
|
+
const indexObject = built?.index && typeof built.index === "object" ? built.index : null
|
|
91
|
+
const cacheKey = [root, l0Limit, l1Limit].join("\u0000")
|
|
92
|
+
const cachedBucket = indexObject ? HIERARCHY_RUNTIME_CACHE.get(indexObject) : null
|
|
93
|
+
const cached = cachedBucket?.get(cacheKey)
|
|
94
|
+
if (cached) {
|
|
95
|
+
return {
|
|
96
|
+
...cached,
|
|
97
|
+
stats: built.stats,
|
|
98
|
+
runtimeCacheHit: true,
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
87
102
|
const nodes = new Map()
|
|
88
103
|
const children = new Map()
|
|
89
104
|
const ensure = (dir) => {
|
|
@@ -122,19 +137,27 @@ export async function buildContextHierarchy(root = process.cwd(), options = {})
|
|
|
122
137
|
.map((node) => buildNodeSummary(node, children, options))
|
|
123
138
|
.sort((a, b) => a.path.localeCompare(b.path))
|
|
124
139
|
|
|
125
|
-
|
|
140
|
+
const result = {
|
|
126
141
|
schemaVersion: 1,
|
|
127
142
|
kind: "ues-context-hierarchy",
|
|
128
143
|
root,
|
|
129
144
|
generatedAt: new Date().toISOString(),
|
|
130
145
|
levels: {
|
|
131
|
-
L0: { maxChars:
|
|
132
|
-
L1: { maxChars:
|
|
146
|
+
L0: { maxChars: l0Limit, purpose: "routing abstract" },
|
|
147
|
+
L1: { maxChars: l1Limit, purpose: "scope overview" },
|
|
133
148
|
L2: { purpose: "source excerpts loaded on demand by the context manifest" },
|
|
134
149
|
},
|
|
135
150
|
nodes: summaries,
|
|
136
151
|
stats: built.stats,
|
|
152
|
+
runtimeCacheHit: false,
|
|
137
153
|
}
|
|
154
|
+
|
|
155
|
+
if (indexObject) {
|
|
156
|
+
const bucket = cachedBucket || new Map()
|
|
157
|
+
bucket.set(cacheKey, result)
|
|
158
|
+
if (!cachedBucket) HIERARCHY_RUNTIME_CACHE.set(indexObject, bucket)
|
|
159
|
+
}
|
|
160
|
+
return result
|
|
138
161
|
}
|
|
139
162
|
|
|
140
163
|
function containsFile(scope, file) {
|
|
@@ -211,5 +234,10 @@ export async function queryContextHierarchy(root, query, options = {}) {
|
|
|
211
234
|
levels: hierarchy.levels,
|
|
212
235
|
scopes,
|
|
213
236
|
stats: hierarchy.stats,
|
|
237
|
+
runtimeCacheHit: hierarchy.runtimeCacheHit === true,
|
|
214
238
|
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function clearContextHierarchyRuntimeCache() {
|
|
242
|
+
HIERARCHY_RUNTIME_CACHE = new WeakMap()
|
|
215
243
|
}
|
package/lib/learning-engine.mjs
CHANGED
|
@@ -1,11 +1,88 @@
|
|
|
1
1
|
import { existsSync } from "node:fs"
|
|
2
|
-
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"
|
|
2
|
+
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises"
|
|
3
3
|
import { createHash } from "node:crypto"
|
|
4
4
|
import path from "node:path"
|
|
5
5
|
import { pairedBenchmarkConfidence } from "./benchmark-confidence.mjs"
|
|
6
6
|
|
|
7
7
|
const LEARNING_DIR = ".ues-learning"
|
|
8
8
|
const STATE_FILE = "LEARNINGS.json"
|
|
9
|
+
const LEARNING_WRITE_TAILS = new Map()
|
|
10
|
+
const LEARNING_LOCK_STALE_MS = 15_000
|
|
11
|
+
const LEARNING_LOCK_WAIT_MS = 20_000
|
|
12
|
+
|
|
13
|
+
function sleep(ms) {
|
|
14
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function learningLockPath(root) {
|
|
18
|
+
return path.join(path.resolve(root), LEARNING_DIR, ".learning-write.lock")
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function withCrossProcessLearningLock(root, fn) {
|
|
22
|
+
const lockDir = learningLockPath(root)
|
|
23
|
+
await mkdir(path.dirname(lockDir), { recursive: true })
|
|
24
|
+
const deadline = Date.now() + LEARNING_LOCK_WAIT_MS
|
|
25
|
+
let delayMs = 8
|
|
26
|
+
|
|
27
|
+
while (true) {
|
|
28
|
+
try {
|
|
29
|
+
await mkdir(lockDir)
|
|
30
|
+
break
|
|
31
|
+
} catch (error) {
|
|
32
|
+
if (error?.code !== "EEXIST") throw error
|
|
33
|
+
const info = await stat(lockDir).catch(() => null)
|
|
34
|
+
if (info && Date.now() - info.mtimeMs > LEARNING_LOCK_STALE_MS) {
|
|
35
|
+
const confirmed = await stat(lockDir).catch(() => null)
|
|
36
|
+
if (
|
|
37
|
+
confirmed &&
|
|
38
|
+
confirmed.ino === info.ino &&
|
|
39
|
+
confirmed.mtimeMs === info.mtimeMs
|
|
40
|
+
) {
|
|
41
|
+
await rm(lockDir, { recursive: true, force: true }).catch(() => {})
|
|
42
|
+
continue
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (Date.now() >= deadline) throw new Error("Timed out waiting for learning write lock")
|
|
46
|
+
await sleep(delayMs)
|
|
47
|
+
delayMs = Math.min(100, delayMs * 2)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
return await fn()
|
|
53
|
+
} finally {
|
|
54
|
+
await rm(lockDir, { recursive: true, force: true }).catch(() => {})
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function withLearningWriteLock(root, fn) {
|
|
59
|
+
const key = path.resolve(root)
|
|
60
|
+
const previous = LEARNING_WRITE_TAILS.get(key) || Promise.resolve()
|
|
61
|
+
let release
|
|
62
|
+
const barrier = new Promise((resolve) => { release = resolve })
|
|
63
|
+
const tail = previous.catch(() => {}).then(() => barrier)
|
|
64
|
+
LEARNING_WRITE_TAILS.set(key, tail)
|
|
65
|
+
|
|
66
|
+
await previous.catch(() => {})
|
|
67
|
+
try {
|
|
68
|
+
return await withCrossProcessLearningLock(root, fn)
|
|
69
|
+
} finally {
|
|
70
|
+
release()
|
|
71
|
+
if (LEARNING_WRITE_TAILS.get(key) === tail) LEARNING_WRITE_TAILS.delete(key)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function atomicJson(file, value) {
|
|
76
|
+
await mkdir(path.dirname(file), { recursive: true })
|
|
77
|
+
const temp = file + "." + process.pid + "." + Date.now() + ".tmp"
|
|
78
|
+
await writeFile(temp, JSON.stringify(value, null, 2) + "\n", "utf8")
|
|
79
|
+
try {
|
|
80
|
+
await rename(temp, file)
|
|
81
|
+
} catch (error) {
|
|
82
|
+
await rm(temp, { force: true }).catch(() => {})
|
|
83
|
+
throw error
|
|
84
|
+
}
|
|
85
|
+
}
|
|
9
86
|
|
|
10
87
|
function idFor(type, key) {
|
|
11
88
|
return createHash("sha256").update(type + ":" + key).digest("hex").slice(0, 16)
|
|
@@ -27,6 +104,22 @@ async function walkJson(dir, limit = 500) {
|
|
|
27
104
|
return files
|
|
28
105
|
}
|
|
29
106
|
|
|
107
|
+
async function mapLimit(items, limit, worker) {
|
|
108
|
+
if (!items.length) return []
|
|
109
|
+
const output = new Array(items.length)
|
|
110
|
+
let cursor = 0
|
|
111
|
+
const width = Math.min(items.length, Math.max(1, Number(limit || 1)))
|
|
112
|
+
const runners = Array.from({ length: width }, async () => {
|
|
113
|
+
while (true) {
|
|
114
|
+
const index = cursor++
|
|
115
|
+
if (index >= items.length) return
|
|
116
|
+
output[index] = await worker(items[index], index)
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
await Promise.all(runners)
|
|
120
|
+
return output
|
|
121
|
+
}
|
|
122
|
+
|
|
30
123
|
function failureReasons(item) {
|
|
31
124
|
const reasons = []
|
|
32
125
|
if (item.timedOut) reasons.push("hard-timeout")
|
|
@@ -71,15 +164,15 @@ function clampRate(value) {
|
|
|
71
164
|
|
|
72
165
|
export async function analyzeEvalTraces(evalDir) {
|
|
73
166
|
const files = await walkJson(path.resolve(evalDir))
|
|
74
|
-
const
|
|
75
|
-
for (const file of files) {
|
|
167
|
+
const batches = await mapLimit(files, 12, async (file) => {
|
|
76
168
|
try {
|
|
77
169
|
const payload = JSON.parse(await readFile(file, "utf8"))
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
82
|
-
}
|
|
170
|
+
return (payload.results || []).map((item) => ({ ...item, source: file }))
|
|
171
|
+
} catch {
|
|
172
|
+
return []
|
|
173
|
+
}
|
|
174
|
+
})
|
|
175
|
+
const results = batches.flat()
|
|
83
176
|
|
|
84
177
|
const reasonCounters = new Map()
|
|
85
178
|
const clusters = new Map()
|
|
@@ -186,48 +279,49 @@ export async function readLearningState(root) {
|
|
|
186
279
|
}
|
|
187
280
|
|
|
188
281
|
export async function saveLearningAnalysis(root, analysis) {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
282
|
+
return withLearningWriteLock(root, async () => {
|
|
283
|
+
const current = await readLearningState(root)
|
|
284
|
+
const acceptedIDs = new Set(current.accepted.map((item) => item.id))
|
|
285
|
+
const existing = new Map(current.proposals.map((item) => [item.id, item]))
|
|
286
|
+
const proposals = analysis.proposals
|
|
287
|
+
.filter((item) => !acceptedIDs.has(item.id))
|
|
288
|
+
.map((item) => ({
|
|
289
|
+
...item,
|
|
290
|
+
firstProposedAt: existing.get(item.id)?.firstProposedAt || new Date().toISOString(),
|
|
291
|
+
lastAnalyzedAt: analysis.analyzedAt,
|
|
292
|
+
}))
|
|
293
|
+
const next = {
|
|
294
|
+
schemaVersion: 2,
|
|
295
|
+
updatedAt: new Date().toISOString(),
|
|
296
|
+
proposals,
|
|
297
|
+
accepted: current.accepted,
|
|
298
|
+
}
|
|
299
|
+
await atomicJson(learningFile(root), next)
|
|
300
|
+
return next
|
|
301
|
+
})
|
|
209
302
|
}
|
|
210
303
|
|
|
211
304
|
// Backward-compatible manual acceptance. Shadow-required proposals remain excluded
|
|
212
305
|
// from retrieval until they are promoted with measured benchmark improvement.
|
|
213
306
|
export async function acceptLearning(root, id) {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
307
|
+
return withLearningWriteLock(root, async () => {
|
|
308
|
+
const current = await readLearningState(root)
|
|
309
|
+
const item = current.proposals.find((entry) => entry.id === id)
|
|
310
|
+
if (!item) throw new Error("unknown learning proposal: " + id)
|
|
311
|
+
const accepted = {
|
|
312
|
+
...item,
|
|
313
|
+
status: item.shadowRequired ? "accepted-awaiting-shadow" : "accepted",
|
|
314
|
+
acceptedAt: new Date().toISOString(),
|
|
315
|
+
}
|
|
316
|
+
const next = {
|
|
317
|
+
schemaVersion: 2,
|
|
318
|
+
updatedAt: accepted.acceptedAt,
|
|
319
|
+
proposals: current.proposals.filter((entry) => entry.id !== id),
|
|
320
|
+
accepted: [...current.accepted.filter((entry) => entry.id !== id), accepted],
|
|
321
|
+
}
|
|
322
|
+
await atomicJson(learningFile(root), next)
|
|
323
|
+
return accepted
|
|
324
|
+
})
|
|
231
325
|
}
|
|
232
326
|
|
|
233
327
|
async function benchmarkValidationFromArtifact(root, reportPath) {
|
|
@@ -295,32 +389,37 @@ async function benchmarkValidationFromArtifact(root, reportPath) {
|
|
|
295
389
|
}
|
|
296
390
|
|
|
297
391
|
export async function promoteLearning(root, id, validation = {}) {
|
|
298
|
-
const
|
|
299
|
-
const
|
|
300
|
-
if (!
|
|
301
|
-
if (
|
|
392
|
+
const initial = await readLearningState(root)
|
|
393
|
+
const initialItem = initial.accepted.find((entry) => entry.id === id)
|
|
394
|
+
if (!initialItem) {
|
|
395
|
+
if (initial.proposals.some((entry) => entry.id === id)) {
|
|
302
396
|
throw new Error("learning proposal must be explicitly accepted before shadow promotion: " + id)
|
|
303
397
|
}
|
|
304
398
|
throw new Error("unknown learning proposal: " + id)
|
|
305
399
|
}
|
|
306
400
|
|
|
307
401
|
const shadowValidation = await benchmarkValidationFromArtifact(root, validation.report)
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
402
|
+
return withLearningWriteLock(root, async () => {
|
|
403
|
+
const current = await readLearningState(root)
|
|
404
|
+
const item = current.accepted.find((entry) => entry.id === id)
|
|
405
|
+
if (!item) throw new Error("learning proposal changed during promotion: " + id)
|
|
406
|
+
|
|
407
|
+
const promotedAt = new Date().toISOString()
|
|
408
|
+
const promoted = {
|
|
409
|
+
...item,
|
|
410
|
+
status: "promoted",
|
|
411
|
+
promotedAt,
|
|
412
|
+
shadowValidation,
|
|
413
|
+
}
|
|
414
|
+
const next = {
|
|
415
|
+
schemaVersion: 2,
|
|
416
|
+
updatedAt: promotedAt,
|
|
417
|
+
proposals: current.proposals.filter((entry) => entry.id !== id),
|
|
418
|
+
accepted: [...current.accepted.filter((entry) => entry.id !== id), promoted],
|
|
419
|
+
}
|
|
420
|
+
await atomicJson(learningFile(root), next)
|
|
421
|
+
return promoted
|
|
422
|
+
})
|
|
324
423
|
}
|
|
325
424
|
|
|
326
425
|
function retrievalTerms(text) {
|