@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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.11.2] - 2026-07-04
4
+
5
+ ### Fixed
6
+
7
+ - 🤖 fix(core): кап runtime-probe виходів (shape-summary + tmp-cwd) і bounded backoff на omlx memory-guard замість миттєвого падіння
8
+
3
9
  ## [0.11.1] - 2026-07-04
4
10
 
5
11
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7n/test",
3
- "version": "0.11.1",
3
+ "version": "0.11.2",
4
4
  "description": "CLI-утиліта @7n/test",
5
5
  "keywords": [
6
6
  "7n",
@@ -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 (the shared machine can't fit the prompt in RAM) is not
13
- * retryable retrying against a fixed RAM ceiling can't succeed, so it
14
- * prints the request body to stdout and terminates the process instead.
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
- * Prints the request body that triggered a memory-guard rejection to stdout
39
- * and throws there's no RAM to retry into. Callers that own the process
40
- * lifecycle (CLI entrypoints) must let this propagate uncaught instead of
41
- * swallowing it like a normal per-file error, so the process exits instead
42
- * of quietly continuing against a RAM ceiling that won't change.
43
- * @param {Error} error the memory-guard error thrown by the model server
44
- * @param {string} requestBody prompt sent to the model
45
- * @returns {never} always throws
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
- throw new Error(`omlx memory-guard: ${error.message}`)
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 throws via `failOnMemoryGuard` instead of retrying — see its docs.
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 terminates the process
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
- for (let attempt = 1; ; attempt++) {
113
+ let memoryAttempts = 0
114
+ let connAttempts = 0
115
+ for (;;) {
66
116
  try {
67
117
  return await fn()
68
118
  } catch (error) {
69
- if (MEMORY_ERROR_RE.test(error.message ?? '')) failOnMemoryGuard(error, requestBody)
70
- if (attempt >= MAX_ATTEMPTS || !RETRYABLE_ERROR_RE.test(error.message ?? '')) throw error
71
- const delay = Math.min(BASE_DELAY_MS * 2 ** (attempt - 1), MAX_DELAY_MS)
72
- const jitter = delay * (0.5 + Math.random() * 0.5)
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 = spawnSync('node', ['--input-type=module'], {
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 = spawnSync('node', ['--input-type=module'], {
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
  }