@7n/test 0.11.1 → 0.11.2
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 +6 -0
- package/package.json +1 -1
- package/src/lib/pi-client.mjs +76 -19
- package/src/lib/runtime-probe.mjs +139 -15
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
package/src/lib/pi-client.mjs
CHANGED
|
@@ -9,11 +9,15 @@
|
|
|
9
9
|
* Both retry transient connection failures (e.g. a shared local model server
|
|
10
10
|
* that's momentarily busy under concurrent load) with exponential backoff
|
|
11
11
|
* instead of failing the caller's file on the first hiccup. A memory-guard
|
|
12
|
-
* rejection
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* rejection gets its own longer bounded backoff: oMLX обслуговує один промпт
|
|
13
|
+
* одночасно, тож відмова зазвичай відображає залишкову пам'ять попереднього
|
|
14
|
+
* запиту, яку сервер вивільняє сам за десятки секунд. Лише після
|
|
15
|
+
* `N_PI_MEMORY_RETRY_ATTEMPTS` невдач тіло запиту друкується в stdout і
|
|
16
|
+
* помилка прокидається як термінальна (виклики-власники процесу мають дати
|
|
17
|
+
* їй впасти, не ковтати як звичайну per-file помилку).
|
|
15
18
|
*/
|
|
16
19
|
import { createAgentSession, SessionManager, ModelRegistry, AuthStorage } from '@earendil-works/pi-coding-agent'
|
|
20
|
+
import { randomInt } from 'node:crypto'
|
|
17
21
|
import { env } from 'node:process'
|
|
18
22
|
import { setTimeout as sleep } from 'node:timers/promises'
|
|
19
23
|
|
|
@@ -33,43 +37,96 @@ export const MEMORY_ERROR_RE = /memory guard|memory limit|prefill would require/
|
|
|
33
37
|
const MAX_ATTEMPTS = Number(env.N_PI_RETRY_ATTEMPTS) || 4
|
|
34
38
|
const BASE_DELAY_MS = Number(env.N_PI_RETRY_DELAY_MS) || 1500
|
|
35
39
|
const MAX_DELAY_MS = 15_000
|
|
40
|
+
/** Скільки разів пробуємо промпт, відкинутий memory guard (включно з першою спробою). */
|
|
41
|
+
const MEMORY_MAX_ATTEMPTS = Number(env.N_PI_MEMORY_RETRY_ATTEMPTS) || 3
|
|
42
|
+
/** Базова затримка перед повтором після memory-guard відмови (експоненційно: 15s → 30s → …). */
|
|
43
|
+
const MEMORY_BASE_DELAY_MS = Number(env.N_PI_MEMORY_RETRY_DELAY_MS) || 15_000
|
|
44
|
+
const MEMORY_MAX_DELAY_MS = 60_000
|
|
36
45
|
|
|
37
46
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* @
|
|
44
|
-
|
|
45
|
-
|
|
47
|
+
* Витягує структуровані поля oMLX-помилки з тексту повідомлення, якщо тіло
|
|
48
|
+
* 400 (`omlx_code`/`estimated_bytes`/`limit_bytes`) потрапило в message.
|
|
49
|
+
* `estimated_bytes`/`limit_bytes` — байти GPU/Metal-пам'яті сервера,
|
|
50
|
+
* ТІЛЬКИ для діагностики й логів (не «скільки символів промпту зрізати»).
|
|
51
|
+
* @param {string} message текст помилки від SDK/сервера
|
|
52
|
+
* @returns {{omlxCode?: string, estimatedBytes?: number, limitBytes?: number} | null} знайдені поля або `null`
|
|
53
|
+
*/
|
|
54
|
+
function parseOmlxErrorDetails(message) {
|
|
55
|
+
const code = /"(?:omlx_)?code"\s*:\s*"([^"]+)"/.exec(message)?.[1]
|
|
56
|
+
const estimated = /"estimated_bytes"\s*:\s*(\d+)/.exec(message)?.[1]
|
|
57
|
+
const limit = /"limit_bytes"\s*:\s*(\d+)/.exec(message)?.[1]
|
|
58
|
+
if (!code && !estimated && !limit) return null
|
|
59
|
+
const details = {}
|
|
60
|
+
if (code) details.omlxCode = code
|
|
61
|
+
if (estimated) details.estimatedBytes = Number(estimated)
|
|
62
|
+
if (limit) details.limitBytes = Number(limit)
|
|
63
|
+
return details
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Розпізнає memory-guard відмову і збагачує помилку структурованими полями
|
|
68
|
+
* (`omlxCode`, `estimatedBytes`, `limitBytes`), коли вони доступні в message.
|
|
69
|
+
* @param {Error} error помилка з виклику моделі
|
|
70
|
+
* @returns {boolean} `true`, якщо це memory-guard відмова
|
|
71
|
+
*/
|
|
72
|
+
function isMemoryGuardError(error) {
|
|
73
|
+
const details = parseOmlxErrorDetails(error.message ?? '')
|
|
74
|
+
if (details) Object.assign(error, details)
|
|
75
|
+
return error.omlxCode === 'prefill_memory_exceeded' || MEMORY_ERROR_RE.test(error.message ?? '')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Термінальний вихід після вичерпання memory-retry: друкує тіло запиту в
|
|
80
|
+
* stdout (лише тут — не на проміжних спробах) і кидає помилку далі,
|
|
81
|
+
* зберігаючи структуровані поля й оригінал у `cause`. Виклики-власники
|
|
82
|
+
* процесу (CLI) мають дати їй впасти, не ковтати як per-file помилку.
|
|
83
|
+
* @param {Error} error memory-guard помилка останньої спроби
|
|
84
|
+
* @param {string} requestBody промпт, надісланий моделі
|
|
85
|
+
* @returns {never} завжди кидає
|
|
46
86
|
*/
|
|
47
87
|
function failOnMemoryGuard(error, requestBody) {
|
|
48
88
|
console.log('--- omlx memory-guard: тіло запиту ---')
|
|
49
89
|
console.log(requestBody)
|
|
50
90
|
console.log(`✗ omlx memory-guard: ${error.message}`)
|
|
51
|
-
|
|
91
|
+
const wrapped = new Error(`omlx memory-guard: ${error.message}`, { cause: error })
|
|
92
|
+
for (const key of ['omlxCode', 'estimatedBytes', 'limitBytes']) {
|
|
93
|
+
if (error[key] !== undefined) wrapped[key] = error[key]
|
|
94
|
+
}
|
|
95
|
+
throw wrapped
|
|
52
96
|
}
|
|
53
97
|
|
|
54
98
|
/**
|
|
55
99
|
* Retries `fn` with exponential backoff + jitter when it throws a transient
|
|
56
100
|
* connection-ish error (shared local model server busy/restarting); other
|
|
57
101
|
* errors (auth, malformed request) are re-thrown immediately. A memory-guard
|
|
58
|
-
* rejection
|
|
102
|
+
* rejection has its own bounded schedule (`MEMORY_MAX_ATTEMPTS` спроб,
|
|
103
|
+
* затримки від `MEMORY_BASE_DELAY_MS` експоненційно): одно-слотова черга
|
|
104
|
+
* oMLX вивільняє залишкову пам'ять попереднього запиту сама, тож повтор
|
|
105
|
+
* того самого промпту після паузи легітимний. Після вичерпання —
|
|
106
|
+
* `failOnMemoryGuard` (друк тіла + термінальний throw).
|
|
59
107
|
* @template T
|
|
60
108
|
* @param {() => Promise<T>} fn operation to retry
|
|
61
|
-
* @param {string} requestBody prompt sent to the model, printed if a memory-guard error
|
|
109
|
+
* @param {string} requestBody prompt sent to the model, printed if a memory-guard error exhausts its retries
|
|
62
110
|
* @returns {Promise<T>} result of the first successful attempt
|
|
63
111
|
*/
|
|
64
112
|
async function withRetry(fn, requestBody) {
|
|
65
|
-
|
|
113
|
+
let memoryAttempts = 0
|
|
114
|
+
let connAttempts = 0
|
|
115
|
+
for (;;) {
|
|
66
116
|
try {
|
|
67
117
|
return await fn()
|
|
68
118
|
} catch (error) {
|
|
69
|
-
if (
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
119
|
+
if (isMemoryGuardError(error)) {
|
|
120
|
+
memoryAttempts++
|
|
121
|
+
if (memoryAttempts >= MEMORY_MAX_ATTEMPTS) failOnMemoryGuard(error, requestBody)
|
|
122
|
+
const delay = Math.min(MEMORY_BASE_DELAY_MS * 2 ** (memoryAttempts - 1), MEMORY_MAX_DELAY_MS)
|
|
123
|
+
await sleep(delay * (0.5 + randomInt(5000) / 10000))
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
connAttempts++
|
|
127
|
+
if (connAttempts >= MAX_ATTEMPTS || !RETRYABLE_ERROR_RE.test(error.message ?? '')) throw error
|
|
128
|
+
const delay = Math.min(BASE_DELAY_MS * 2 ** (connAttempts - 1), MAX_DELAY_MS)
|
|
129
|
+
const jitter = delay * (0.5 + randomInt(5000) / 10000)
|
|
73
130
|
await sleep(jitter)
|
|
74
131
|
}
|
|
75
132
|
}
|
|
@@ -9,10 +9,26 @@
|
|
|
9
9
|
* 4. probeHelpers — extracts non-exported helper functions from source and calls them
|
|
10
10
|
* with generic param combos to reveal their actual output shapes.
|
|
11
11
|
*/
|
|
12
|
-
import { readFileSync } from 'node:fs'
|
|
12
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
|
13
13
|
import { spawnSync } from 'node:child_process'
|
|
14
|
+
import { tmpdir } from 'node:os'
|
|
15
|
+
import { join } from 'node:path'
|
|
14
16
|
|
|
15
17
|
const PROBE_TIMEOUT_MS = 10_000
|
|
18
|
+
/**
|
|
19
|
+
* Кап на серіалізований probe-вихід (символів). Довший вихід замінюється
|
|
20
|
+
* shape-summary — стислим описом форми замість значення: гігантський дамп
|
|
21
|
+
* (напр. функція, що читає файл проєкту) інакше роздуває LLM-промпт до
|
|
22
|
+
* сотень тисяч символів і впирається в memory guard моделі, а обрізаний
|
|
23
|
+
* JSON модель копіює в expected як сміття.
|
|
24
|
+
*/
|
|
25
|
+
const PROBE_OUTPUT_MAX_CHARS = 600
|
|
26
|
+
/** Максимум probe-рядків на один export — багато середніх виходів теж не мають роздувати промпт. */
|
|
27
|
+
const PROBE_MAX_ENTRIES_PER_EXPORT = 12
|
|
28
|
+
/** Глибина рекурсії shape-summary. */
|
|
29
|
+
const SHAPE_MAX_DEPTH = 4
|
|
30
|
+
/** Скільки ключів об'єкта показує shape-summary до «…». */
|
|
31
|
+
const SHAPE_MAX_KEYS = 8
|
|
16
32
|
|
|
17
33
|
/** Generic argument combos to try when probing async/fetch functions. */
|
|
18
34
|
const FETCH_ARG_COMBOS = [
|
|
@@ -70,6 +86,97 @@ const PROBE_INPUTS = [
|
|
|
70
86
|
['42', '42']
|
|
71
87
|
]
|
|
72
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Рекурсивно описує форму значення без самих даних.
|
|
91
|
+
* @param {unknown} value розпарсене JSON-значення
|
|
92
|
+
* @param {number} [depth] залишкова глибина рекурсії; за замовчуванням `SHAPE_MAX_DEPTH`
|
|
93
|
+
* @returns {string} стислий опис форми, напр. `Array(34) of {file: string, mutants: Array(12)}`
|
|
94
|
+
*/
|
|
95
|
+
export function describeShape(value, depth = SHAPE_MAX_DEPTH) {
|
|
96
|
+
if (value === null) return 'null'
|
|
97
|
+
if (Array.isArray(value)) {
|
|
98
|
+
if (value.length === 0) return 'Array(0)'
|
|
99
|
+
if (depth <= 0) return `Array(${value.length})`
|
|
100
|
+
return `Array(${value.length}) of ${describeShape(value[0], depth - 1)}`
|
|
101
|
+
}
|
|
102
|
+
const type = typeof value
|
|
103
|
+
if (type === 'object') {
|
|
104
|
+
const keys = Object.keys(value)
|
|
105
|
+
if (depth <= 0 || keys.length === 0) return 'Object'
|
|
106
|
+
const shown = keys.slice(0, SHAPE_MAX_KEYS)
|
|
107
|
+
const inner =
|
|
108
|
+
depth > 1 ? shown.map(k => `${k}: ${describeShape(value[k], depth - 1)}`).join(', ') : shown.join(', ')
|
|
109
|
+
return `{${inner}${keys.length > shown.length ? ', …' : ''}}`
|
|
110
|
+
}
|
|
111
|
+
if (type === 'string') return 'string'
|
|
112
|
+
return type
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Обмежує серіалізований probe-вихід: до `PROBE_OUTPUT_MAX_CHARS` — без змін,
|
|
117
|
+
* довший — shape-summary замість значення (модель бачить структуру для
|
|
118
|
+
* asserts на форму, але не тягне дамп у промпт і не копіює його в expected).
|
|
119
|
+
* @param {string} serialized JSON-серіалізований вихід probe
|
|
120
|
+
* @returns {string} оригінал або `[shape-summary, ~N chars] <форма>`
|
|
121
|
+
*/
|
|
122
|
+
export function capProbeOutput(serialized) {
|
|
123
|
+
if (typeof serialized !== 'string' || serialized.length <= PROBE_OUTPUT_MAX_CHARS) return serialized
|
|
124
|
+
let shape
|
|
125
|
+
try {
|
|
126
|
+
shape = describeShape(JSON.parse(serialized))
|
|
127
|
+
} catch {
|
|
128
|
+
shape = 'string'
|
|
129
|
+
}
|
|
130
|
+
const summary = `[shape-summary, ~${serialized.length} chars] ${shape}`
|
|
131
|
+
return summary.length > PROBE_OUTPUT_MAX_CHARS ? `${summary.slice(0, PROBE_OUTPUT_MAX_CHARS - 1)}…` : summary
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Застосовує кап виходів і ліміт кількості рядків до результатів `probeModule`.
|
|
136
|
+
* @param {Record<string, Array<{input: string, output: string}> | {constant: string}>} results сирі результати з дочірнього процесу
|
|
137
|
+
* @returns {typeof results} результати з capped-виходами
|
|
138
|
+
*/
|
|
139
|
+
function capModuleResults(results) {
|
|
140
|
+
const capped = {}
|
|
141
|
+
for (const [name, section] of Object.entries(results)) {
|
|
142
|
+
if (Array.isArray(section)) {
|
|
143
|
+
capped[name] = section
|
|
144
|
+
.slice(0, PROBE_MAX_ENTRIES_PER_EXPORT)
|
|
145
|
+
.map(entry => ({ ...entry, output: capProbeOutput(entry.output) }))
|
|
146
|
+
} else if (section && typeof section === 'object' && 'constant' in section) {
|
|
147
|
+
capped[name] = { constant: capProbeOutput(section.constant) }
|
|
148
|
+
} else {
|
|
149
|
+
capped[name] = section
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return capped
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Запускає node з probe-скриптом у порожній тимчасовій cwd: відносні
|
|
157
|
+
* I/O-читання probe-ованих функцій (напр. `readFile('COVERAGE.md')` при
|
|
158
|
+
* порожньому аргументі шляху) не бачать файлів проєкту — вихід
|
|
159
|
+
* детермінований між машинами і не може випадково затягнути великий
|
|
160
|
+
* файл репозиторію у LLM-промпт.
|
|
161
|
+
* @param {string} script код для виконання
|
|
162
|
+
* @param {number} [timeout] ліміт очікування в мс; за замовчуванням `PROBE_TIMEOUT_MS`
|
|
163
|
+
* @returns {import('node:child_process').SpawnSyncReturns<string>} результат spawnSync
|
|
164
|
+
*/
|
|
165
|
+
function spawnProbe(script, timeout = PROBE_TIMEOUT_MS) {
|
|
166
|
+
const cwd = mkdtempSync(join(tmpdir(), 'n-probe-'))
|
|
167
|
+
try {
|
|
168
|
+
return spawnSync('node', ['--input-type=module'], {
|
|
169
|
+
input: script,
|
|
170
|
+
encoding: 'utf8',
|
|
171
|
+
timeout,
|
|
172
|
+
env: { ...process.env },
|
|
173
|
+
cwd
|
|
174
|
+
})
|
|
175
|
+
} finally {
|
|
176
|
+
rmSync(cwd, { recursive: true, force: true })
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
73
180
|
/**
|
|
74
181
|
* Пробує експорти модуля у дочірньому процесі й повертає фактичні виходи.
|
|
75
182
|
* @param {string} absFilePath абсолютний шлях до джерела модуля
|
|
@@ -122,18 +229,13 @@ for (const name of names) {
|
|
|
122
229
|
process.stdout.write(JSON.stringify(results))
|
|
123
230
|
`
|
|
124
231
|
|
|
125
|
-
const proc =
|
|
126
|
-
input: script,
|
|
127
|
-
encoding: 'utf8',
|
|
128
|
-
timeout: PROBE_TIMEOUT_MS,
|
|
129
|
-
env: { ...process.env }
|
|
130
|
-
})
|
|
232
|
+
const proc = spawnProbe(script)
|
|
131
233
|
|
|
132
234
|
if (!proc.stdout) return {}
|
|
133
235
|
try {
|
|
134
236
|
const parsed = JSON.parse(proc.stdout)
|
|
135
237
|
if (parsed.__importError) return {}
|
|
136
|
-
return parsed
|
|
238
|
+
return capModuleResults(parsed)
|
|
137
239
|
} catch {
|
|
138
240
|
return {}
|
|
139
241
|
}
|
|
@@ -150,12 +252,7 @@ process.stdout.write(JSON.stringify(results))
|
|
|
150
252
|
* @returns {Record<string, unknown> | null} розпарсений JSON або `null` при помилці
|
|
151
253
|
*/
|
|
152
254
|
function runProbeScript(script, timeout = PROBE_TIMEOUT_MS) {
|
|
153
|
-
const proc =
|
|
154
|
-
input: script,
|
|
155
|
-
encoding: 'utf8',
|
|
156
|
-
timeout,
|
|
157
|
-
env: { ...process.env }
|
|
158
|
-
})
|
|
255
|
+
const proc = spawnProbe(script, timeout)
|
|
159
256
|
if (!proc.stdout) return null
|
|
160
257
|
try {
|
|
161
258
|
const parsed = JSON.parse(proc.stdout)
|
|
@@ -211,6 +308,33 @@ process.stdout.write(JSON.stringify(results))
|
|
|
211
308
|
return runProbeScript(script) ?? {}
|
|
212
309
|
}
|
|
213
310
|
|
|
311
|
+
/**
|
|
312
|
+
* Застосовує кап до результатів `probeHelpers`: завеликий `result`
|
|
313
|
+
* замінюється shape-summary рядком, кількість комбо на helper обмежується.
|
|
314
|
+
* @param {Record<string, Array<{params: Record<string, unknown>, result: unknown}>>} results сирі результати helper-probe
|
|
315
|
+
* @returns {typeof results} результати з capped-виходами
|
|
316
|
+
*/
|
|
317
|
+
function capHelperResults(results) {
|
|
318
|
+
const capped = {}
|
|
319
|
+
for (const [name, entries] of Object.entries(results)) {
|
|
320
|
+
if (!Array.isArray(entries)) {
|
|
321
|
+
capped[name] = entries
|
|
322
|
+
continue
|
|
323
|
+
}
|
|
324
|
+
capped[name] = entries.slice(0, PROBE_MAX_ENTRIES_PER_EXPORT).map(entry => {
|
|
325
|
+
let serialized
|
|
326
|
+
try {
|
|
327
|
+
serialized = JSON.stringify(entry.result)
|
|
328
|
+
} catch {
|
|
329
|
+
return entry
|
|
330
|
+
}
|
|
331
|
+
if (typeof serialized !== 'string' || serialized.length <= PROBE_OUTPUT_MAX_CHARS) return entry
|
|
332
|
+
return { ...entry, result: capProbeOutput(serialized) }
|
|
333
|
+
})
|
|
334
|
+
}
|
|
335
|
+
return capped
|
|
336
|
+
}
|
|
337
|
+
|
|
214
338
|
// ---------------------------------------------------------------------------
|
|
215
339
|
// Probe 3: time-variant detection
|
|
216
340
|
// ---------------------------------------------------------------------------
|
|
@@ -386,5 +510,5 @@ for (const name of ${names}) {
|
|
|
386
510
|
}
|
|
387
511
|
process.stdout.write(JSON.stringify(results))
|
|
388
512
|
`
|
|
389
|
-
return runProbeScript(script) ?? {}
|
|
513
|
+
return capHelperResults(runProbeScript(script) ?? {})
|
|
390
514
|
}
|