opencode-agent-skill 15.1.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.
Files changed (37) hide show
  1. package/README.md +3 -3
  2. package/docs/V15-MANAGED-RUNTIME.md +18 -0
  3. package/global-config/agents/verifier.md +6 -2
  4. package/lib/affected-tests.mjs +21 -4
  5. package/lib/capability-fabric.mjs +107 -48
  6. package/lib/code-intelligence/index.mjs +3 -15
  7. package/lib/code-intelligence/lsp-pool.mjs +694 -0
  8. package/lib/code-intelligence/lsp-provider.mjs +160 -20
  9. package/lib/compaction-resume-guard.mjs +76 -4
  10. package/lib/completion-auditor.mjs +20 -0
  11. package/lib/context-manifest.mjs +38 -21
  12. package/lib/decision-policy.mjs +40 -2
  13. package/lib/evidence-store.mjs +121 -31
  14. package/lib/executable-probe.mjs +69 -0
  15. package/lib/hierarchical-context.mjs +31 -3
  16. package/lib/learning-engine.mjs +164 -65
  17. package/lib/memory-engine.mjs +163 -86
  18. package/lib/model-config.mjs +86 -7
  19. package/lib/performance-fabric.mjs +92 -0
  20. package/lib/permission-policy.mjs +232 -0
  21. package/lib/pi-rpc-pool.mjs +170 -5
  22. package/lib/provider-recovery.mjs +51 -0
  23. package/lib/repo-graph.mjs +38 -5
  24. package/lib/runtime-events.mjs +31 -10
  25. package/lib/semantic-index.mjs +67 -20
  26. package/lib/skill-compiler.mjs +84 -18
  27. package/lib/task-policy.mjs +80 -7
  28. package/lib/trajectory.mjs +25 -1
  29. package/lib/untrusted-output.mjs +106 -0
  30. package/package.json +8 -6
  31. package/pi/extensions/ues-child-runtime.ts +78 -3
  32. package/pi/extensions/ues.ts +695 -81
  33. package/scripts/benchmark-lsp-pool.mjs +128 -0
  34. package/scripts/check-release-consistency.mjs +16 -4
  35. package/scripts/check-source-integrity.mjs +60 -1
  36. package/scripts/run-test-suite.mjs +143 -0
  37. package/scripts/smoke-package-closure.mjs +4 -0
@@ -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 now = new Date().toISOString()
110
- const existing = existsSync(target.meta)
111
- ? JSON.parse(await readFile(target.meta, "utf8").catch(() => "{}"))
112
- : null
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
- if (!existsSync(target.data)) await writeFile(target.data, encoded.bytes)
191
+ if (!existsSync(target.data)) {
192
+ await atomicWrite(target.data, encoded.bytes)
193
+ }
115
194
 
116
- const preview = encoded.encoding === "utf8"
117
- ? encoded.bytes.toString("utf8", 0, Math.min(encoded.bytes.length, 600))
118
- : null
195
+ const preview = encoded.encoding === "utf8"
196
+ ? encoded.bytes.toString("utf8", 0, Math.min(encoded.bytes.length, 600))
197
+ : null
119
198
 
120
- const metadata = {
121
- schemaVersion: STORE_VERSION,
122
- ref: evidenceReference(hash),
123
- sha256: hash,
124
- bytes: encoded.bytes.length,
125
- encoding: encoded.encoding,
126
- mediaType: encoded.mediaType,
127
- kind: options.kind || existing?.kind || "tool-output",
128
- source: options.source || existing?.source || null,
129
- summary: options.summary || existing?.summary || null,
130
- createdAt: existing?.createdAt || now,
131
- lastSeenAt: now,
132
- preview,
133
- }
134
- await writeFile(target.meta, JSON.stringify(metadata, null, 2) + "\n", "utf8")
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
- const writes = (PUT_COUNTS.get(root) || 0) + 1
137
- PUT_COUNTS.set(root, writes)
138
- if (writes % AUTO_GC_EVERY_WRITES === 0 || encoded.bytes.length >= AUTO_GC_LARGE_ITEM_BYTES) {
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
- export async function gcEvidenceStore(root = process.cwd(), options = {}) {
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
+ }
@@ -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
- return {
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: Math.max(96, Number(options.l0Chars || DEFAULT_L0)), purpose: "routing abstract" },
132
- L1: { maxChars: Math.max(512, Number(options.l1Chars || DEFAULT_L1)), purpose: "scope overview" },
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
  }
@@ -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 results = []
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
- for (const item of payload.results || []) {
79
- results.push({ ...item, source: file })
80
- }
81
- } catch {}
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
- const dir = path.dirname(learningFile(root))
190
- await mkdir(dir, { recursive: true })
191
- const current = await readLearningState(root)
192
- const acceptedIDs = new Set(current.accepted.map((item) => item.id))
193
- const existing = new Map(current.proposals.map((item) => [item.id, item]))
194
- const proposals = analysis.proposals
195
- .filter((item) => !acceptedIDs.has(item.id))
196
- .map((item) => ({
197
- ...item,
198
- firstProposedAt: existing.get(item.id)?.firstProposedAt || new Date().toISOString(),
199
- lastAnalyzedAt: analysis.analyzedAt,
200
- }))
201
- const next = {
202
- schemaVersion: 2,
203
- updatedAt: new Date().toISOString(),
204
- proposals,
205
- accepted: current.accepted,
206
- }
207
- await writeFile(learningFile(root), JSON.stringify(next, null, 2) + "\n", "utf8")
208
- return next
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
- const current = await readLearningState(root)
215
- const item = current.proposals.find((entry) => entry.id === id)
216
- if (!item) throw new Error("unknown learning proposal: " + id)
217
- const accepted = {
218
- ...item,
219
- status: item.shadowRequired ? "accepted-awaiting-shadow" : "accepted",
220
- acceptedAt: new Date().toISOString(),
221
- }
222
- const next = {
223
- schemaVersion: 2,
224
- updatedAt: accepted.acceptedAt,
225
- proposals: current.proposals.filter((entry) => entry.id !== id),
226
- accepted: [...current.accepted.filter((entry) => entry.id !== id), accepted],
227
- }
228
- await mkdir(path.dirname(learningFile(root)), { recursive: true })
229
- await writeFile(learningFile(root), JSON.stringify(next, null, 2) + "\n", "utf8")
230
- return accepted
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 current = await readLearningState(root)
299
- const item = current.accepted.find((entry) => entry.id === id)
300
- if (!item) {
301
- if (current.proposals.some((entry) => entry.id === id)) {
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
- const promotedAt = new Date().toISOString()
309
- const promoted = {
310
- ...item,
311
- status: "promoted",
312
- promotedAt,
313
- shadowValidation,
314
- }
315
- const next = {
316
- schemaVersion: 2,
317
- updatedAt: promotedAt,
318
- proposals: current.proposals.filter((entry) => entry.id !== id),
319
- accepted: [...current.accepted.filter((entry) => entry.id !== id), promoted],
320
- }
321
- await mkdir(path.dirname(learningFile(root)), { recursive: true })
322
- await writeFile(learningFile(root), JSON.stringify(next, null, 2) + "\n", "utf8")
323
- return promoted
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) {