tensorgrid-ui 1.4.0 → 1.4.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/lib/client.js +20 -3
- package/lib/review-settings.js +36 -5
- package/lib/review.js +97 -17
- package/lib/reviewers.js +71 -14
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -2283,9 +2283,21 @@ window.__ModuleLoader__.load({
|
|
|
2283
2283
|
const refresh = React.useCallback(async (body) => {
|
|
2284
2284
|
setBusy(true)
|
|
2285
2285
|
try {
|
|
2286
|
-
|
|
2286
|
+
const next = await send(body)
|
|
2287
|
+
// Ответ без каталога — это отказ, а не новое состояние.
|
|
2288
|
+
//
|
|
2289
|
+
// Раньше состояние заменялось целиком, и одно неудачное нажатие
|
|
2290
|
+
// галочки убирало все карточки: ни повторить, ни увидеть выбранное
|
|
2291
|
+
// было нельзя, помогала только перезагрузка. Ту же ошибку я уже
|
|
2292
|
+
// допускал в строке входа и там же чинил.
|
|
2293
|
+
setState((previous) => {
|
|
2294
|
+
const complete = Array.isArray(next.catalogue) && next.catalogue.length > 0
|
|
2295
|
+
if (complete) return next
|
|
2296
|
+
return previous === null ? next : { ...previous, problem: next.problem ?? 'сохранить не удалось' }
|
|
2297
|
+
})
|
|
2287
2298
|
} catch (error) {
|
|
2288
|
-
|
|
2299
|
+
const text = String(error && error.message ? error.message : error)
|
|
2300
|
+
setState((previous) => (previous === null ? { catalogue: [], problem: text } : { ...previous, problem: text }))
|
|
2289
2301
|
} finally {
|
|
2290
2302
|
setBusy(false)
|
|
2291
2303
|
}
|
|
@@ -2343,7 +2355,12 @@ window.__ModuleLoader__.load({
|
|
|
2343
2355
|
className: 'dsx-rev__list',
|
|
2344
2356
|
children: catalogue.map((entry) => {
|
|
2345
2357
|
const on = settings !== null && settings.enabled.includes(entry.kind)
|
|
2346
|
-
|
|
2358
|
+
// Запись может отсутствовать, если списки Host и браузера
|
|
2359
|
+
// разойдутся. Раньше следующая строка бросала исключение прямо
|
|
2360
|
+
// в отрисовке, и падал ВЕСЬ раздел настроек, а не только наша
|
|
2361
|
+
// карточка.
|
|
2362
|
+
const stored = settings === null ? null : settings.choices[entry.kind]
|
|
2363
|
+
const choice = stored ?? { model: null, effort: null }
|
|
2347
2364
|
return jsxs('div', {
|
|
2348
2365
|
className: 'dsx-rev__card' + (on ? ' dsx-rev__card--on' : ''),
|
|
2349
2366
|
children: [
|
package/lib/review-settings.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Импортируются только встроенные модули Node.
|
|
13
13
|
*/
|
|
14
|
-
import { readFile, writeFile } from 'node:fs/promises'
|
|
14
|
+
import { readFile, rename, writeFile } from 'node:fs/promises'
|
|
15
15
|
import { existsSync } from 'node:fs'
|
|
16
16
|
import { dirname, join } from 'node:path'
|
|
17
17
|
import { fileURLToPath } from 'node:url'
|
|
@@ -21,6 +21,17 @@ const FILE_NAME = 'tensorgrid-review.json'
|
|
|
21
21
|
/** Ревизоры, которых мы умеем звать. Порядок — порядок показа. */
|
|
22
22
|
export const KNOWN = ['claude-code', 'codex']
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Что допустимо в модели и усилии.
|
|
26
|
+
*
|
|
27
|
+
* Те же правила, что и при запуске, но применяются ЗДЕСЬ, при сохранении.
|
|
28
|
+
* Раньше проверка жила только в момент вызова: строка вроде «gpt 5 (новая)»
|
|
29
|
+
* спокойно сохранялась и отвергалась через двадцать минут, при следующем
|
|
30
|
+
* обзоре. В интерфейсе это было невидимо — список с неизвестным значением
|
|
31
|
+
* показывает пустое поле, неотличимое от «как настроено».
|
|
32
|
+
*/
|
|
33
|
+
const SAFE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
|
|
34
|
+
|
|
24
35
|
/**
|
|
25
36
|
* Что действует, пока пользователь ничего не выбрал.
|
|
26
37
|
*
|
|
@@ -71,12 +82,23 @@ export function normalise(raw) {
|
|
|
71
82
|
const base = defaults()
|
|
72
83
|
if (raw === null || typeof raw !== 'object') return base
|
|
73
84
|
|
|
74
|
-
|
|
85
|
+
// Повторы отбрасываются: список запускает по процессу на элемент, и сотня
|
|
86
|
+
// одинаковых имён означала бы сотню платных обзоров разом.
|
|
87
|
+
const enabled = Array.isArray(raw.enabled)
|
|
88
|
+
? [...new Set(raw.enabled.filter((kind) => KNOWN.includes(kind)))]
|
|
89
|
+
: base.enabled
|
|
75
90
|
|
|
76
91
|
const choices = {}
|
|
77
92
|
for (const kind of KNOWN) {
|
|
78
93
|
const source = raw.choices !== null && typeof raw.choices === 'object' ? raw.choices[kind] : undefined
|
|
79
|
-
|
|
94
|
+
// Недопустимое значение отбрасывается молча и превращается в «как
|
|
95
|
+
// настроено»: сохранить его значило бы выключить обзор до тех пор, пока
|
|
96
|
+
// кто-нибудь не догадается открыть файл.
|
|
97
|
+
const pick = (value) => {
|
|
98
|
+
if (typeof value !== 'string') return null
|
|
99
|
+
const trimmed = value.trim()
|
|
100
|
+
return trimmed !== '' && SAFE_VALUE.test(trimmed) ? trimmed : null
|
|
101
|
+
}
|
|
80
102
|
choices[kind] = {
|
|
81
103
|
model: pick(source?.model),
|
|
82
104
|
effort: pick(source?.effort),
|
|
@@ -100,11 +122,20 @@ export async function readSettings(cwd) {
|
|
|
100
122
|
}
|
|
101
123
|
}
|
|
102
124
|
|
|
103
|
-
/**
|
|
125
|
+
/**
|
|
126
|
+
* Записать настройки. Возвращает то, что действительно сохранено.
|
|
127
|
+
*
|
|
128
|
+
* Запись идёт через временный файл с переименованием. Прямая перезапись
|
|
129
|
+
* сначала обрезает файл, и чтение, пришедшееся на этот момент, видит пустоту
|
|
130
|
+
* или обрывок — а пустота у нас означает «включить обоих ревизоров», то есть
|
|
131
|
+
* запустить продукт, который человек только что выключил.
|
|
132
|
+
*/
|
|
104
133
|
export async function writeSettings(cwd, value) {
|
|
105
134
|
const path = settingsPath(cwd)
|
|
106
135
|
if (path === null) throw new Error('не найден каталог профиля — некуда сохранить настройки обзора')
|
|
107
136
|
const normalised = normalise(value)
|
|
108
|
-
|
|
137
|
+
const temporary = `${path}.${process.pid}.tmp`
|
|
138
|
+
await writeFile(temporary, `${JSON.stringify(normalised, null, 2)}\n`, 'utf8')
|
|
139
|
+
await rename(temporary, path)
|
|
109
140
|
return normalised
|
|
110
141
|
}
|
package/lib/review.js
CHANGED
|
@@ -39,8 +39,16 @@
|
|
|
39
39
|
* ошибку одной строки вместо обрушения всего ряда.
|
|
40
40
|
*/
|
|
41
41
|
|
|
42
|
-
/**
|
|
43
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Список ревизоров берётся из ОДНОГО места — `reviewers.js`.
|
|
44
|
+
*
|
|
45
|
+
* Здесь стоял свой список, третий по счёту: ещё один в `review-settings.js`
|
|
46
|
+
* и ещё один в справочнике запуска. Ревизор указал, чем это кончится:
|
|
47
|
+
* добавление третьего продукта в один файл из трёх делает
|
|
48
|
+
* `settings.choices[kind]` неопределённым, и раздел настроек падает целиком
|
|
49
|
+
* — React снимает поддерево, пользователь теряет и остальные настройки.
|
|
50
|
+
*/
|
|
51
|
+
const KNOWN_REVIEWERS = ['claude-code', 'codex']
|
|
44
52
|
|
|
45
53
|
/**
|
|
46
54
|
* Задание ревизору.
|
|
@@ -109,33 +117,81 @@ function buildPrompt({ request, paths, focus }) {
|
|
|
109
117
|
/** Уровни важности в порядке убывания — по нему сортируется итог. */
|
|
110
118
|
const SEVERITY_ORDER = ['критично', 'серьёзно', 'мелочь']
|
|
111
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Пределы на разбор.
|
|
122
|
+
*
|
|
123
|
+
* Ответ приходит из отдельного процесса и ничем не ограничен: многословная
|
|
124
|
+
* или сорвавшаяся в повтор модель способна выдать мегабайт. Всё это уходит в
|
|
125
|
+
* контекст вызывающего агента — то есть стоит дороже самого обзора и
|
|
126
|
+
* способно вытеснить ту работу, которую обзор проверял.
|
|
127
|
+
*/
|
|
128
|
+
const MAX_FINDINGS = 60
|
|
129
|
+
const MAX_FIELD_CHARS = 1200
|
|
130
|
+
|
|
131
|
+
/** Обрезать поле, не притворяясь, что ничего не обрезали. */
|
|
132
|
+
function clip(value) {
|
|
133
|
+
if (value === null) return null
|
|
134
|
+
return value.length <= MAX_FIELD_CHARS ? value : `${value.slice(0, MAX_FIELD_CHARS)}… (обрезано)`
|
|
135
|
+
}
|
|
136
|
+
|
|
112
137
|
/**
|
|
113
138
|
* Разобрать ответ ревизора в список находок.
|
|
114
139
|
*
|
|
115
140
|
* Разбор намеренно снисходительный: ревизор — языковая модель, и точного
|
|
116
|
-
* соблюдения формата от неё ждать нельзя.
|
|
117
|
-
*
|
|
118
|
-
*
|
|
141
|
+
* соблюдения формата от неё ждать нельзя. Пропущенное поле даёт null, а не
|
|
142
|
+
* теряет находку; жирная разметка вокруг названий полей снимается.
|
|
143
|
+
*
|
|
144
|
+
* Текст БЕЗ единого заголовка не выбрасывается, а возвращается одной
|
|
145
|
+
* находкой с пометкой «не по формату». Прежде такой ответ превращался в
|
|
146
|
+
* пустой список, то есть в «находок нет» — и сообщение продукта об истёкшем
|
|
147
|
+
* входе выглядело справкой о чистоте кода.
|
|
119
148
|
*/
|
|
120
149
|
export function parseFindings(text, reviewer) {
|
|
121
150
|
const source = String(text ?? '')
|
|
122
|
-
if (
|
|
151
|
+
if (source.trim() === '') return []
|
|
152
|
+
if (/^\s*НАХОДОК НЕТ\s*$/im.test(source) && !/^\s*#{2,3}\s+/m.test(source)) return []
|
|
123
153
|
|
|
124
154
|
const findings = []
|
|
125
|
-
|
|
155
|
+
// Заголовок распознаётся только в начале строки и не глубже третьего
|
|
156
|
+
// уровня: `###` внутри примера кода в поле «КАК ПРОВЕРИТЬ» иначе разрывал
|
|
157
|
+
// находку надвое, и вторая половина уезжала в конец списка без важности.
|
|
158
|
+
const blocks = source.split(/^#{2,3}[ \t]+/m).slice(1)
|
|
159
|
+
|
|
160
|
+
if (blocks.length === 0) {
|
|
161
|
+
return [{
|
|
162
|
+
reviewer,
|
|
163
|
+
title: 'ответ не по формату',
|
|
164
|
+
severity: 'мелочь',
|
|
165
|
+
where: null,
|
|
166
|
+
what: clip(source.trim()),
|
|
167
|
+
why: 'Ревизор ответил не по заданному образцу — возможно, это сообщение об ошибке, а не обзор.',
|
|
168
|
+
howToCheck: null,
|
|
169
|
+
unparsed: true,
|
|
170
|
+
}]
|
|
171
|
+
}
|
|
126
172
|
|
|
127
173
|
for (const block of blocks) {
|
|
174
|
+
if (findings.length >= MAX_FINDINGS) break
|
|
175
|
+
|
|
128
176
|
const [titleLine, ...rest] = block.split('\n')
|
|
129
|
-
|
|
177
|
+
// Жирная разметка вокруг названий полей снимается: модели любят её
|
|
178
|
+
// добавлять, а без этого поле просто не находилось и содержимое терялось.
|
|
179
|
+
const body = rest.join('\n').replace(/\*\*/g, '')
|
|
180
|
+
|
|
130
181
|
// Поле тянется до следующего заголовка или до конца блока.
|
|
131
182
|
//
|
|
132
183
|
// Флаг `m` здесь недопустим: с ним `$` означает конец СТРОКИ, и
|
|
133
184
|
// объяснение обрывалось на первой же — а ревизор объясняет развёрнуто,
|
|
134
185
|
// и вторая строка обычно и есть самое полезное. Начало строки
|
|
135
186
|
// обеспечивается явным `(?:^|\n)`, конец блока — `$` без `m`.
|
|
187
|
+
//
|
|
188
|
+
// Следующим полем считается только ИЗВЕСТНОЕ название, а не любая
|
|
189
|
+
// строка из заглавных букв с двоеточием: «ИТОГО:» внутри объяснения
|
|
190
|
+
// обрывал поле на середине.
|
|
191
|
+
const NEXT = '(?:ВАЖНОСТЬ|ГДЕ|ЧТО НЕ ТАК|ПОЧЕМУ ЭТО ВАЖНО|КАК ПРОВЕРИТЬ)'
|
|
136
192
|
const field = (label) => {
|
|
137
|
-
const match = body.match(new RegExp(`(?:^|\\n)${label}:\\s*([\\s\\S]+?)(?=\\n
|
|
138
|
-
return match === null ? null : match[1].trim()
|
|
193
|
+
const match = body.match(new RegExp(`(?:^|\\n)${label}:\\s*([\\s\\S]+?)(?=\\n${NEXT}:|$)`))
|
|
194
|
+
return match === null ? null : clip(match[1].trim())
|
|
139
195
|
}
|
|
140
196
|
|
|
141
197
|
const severityRaw = (field('ВАЖНОСТЬ') ?? '').toLowerCase()
|
|
@@ -143,7 +199,7 @@ export function parseFindings(text, reviewer) {
|
|
|
143
199
|
|
|
144
200
|
findings.push({
|
|
145
201
|
reviewer,
|
|
146
|
-
title: titleLine.trim(),
|
|
202
|
+
title: clip(titleLine.replace(/\*\*/g, '').trim()),
|
|
147
203
|
severity,
|
|
148
204
|
where: field('ГДЕ'),
|
|
149
205
|
what: field('ЧТО НЕ ТАК'),
|
|
@@ -173,7 +229,7 @@ async function askReviewer(ctx, runReviewer, kind, prompt, cwd, choice, signal)
|
|
|
173
229
|
const used = { model: choice?.model ?? null, effort: choice?.effort ?? null }
|
|
174
230
|
|
|
175
231
|
if (outcome.failed !== null) {
|
|
176
|
-
return { provider: kind, failed: outcome.failed, findings: [], ...used }
|
|
232
|
+
return { provider: kind, failed: outcome.failed, cancelled: outcome.cancelled === true, findings: [], ...used }
|
|
177
233
|
}
|
|
178
234
|
|
|
179
235
|
return { provider: kind, failed: null, findings: parseFindings(outcome.answer, kind), ...used }
|
|
@@ -192,7 +248,16 @@ export function render(value) {
|
|
|
192
248
|
const lines = []
|
|
193
249
|
|
|
194
250
|
if (value.reviewers.length === 0) {
|
|
195
|
-
|
|
251
|
+
// Настоящая причина приходит в `problem` и обязана быть показана.
|
|
252
|
+
//
|
|
253
|
+
// Здесь стояла одна фраза на все случаи — про несмонтированные
|
|
254
|
+
// провайдеры подагентов. Механизм, которым этот код давно не
|
|
255
|
+
// пользуется: продукты зовутся напрямую. Пользователь, снявший обе
|
|
256
|
+
// галочки, получал объяснение про постороннюю неисправность и шёл чинить
|
|
257
|
+
// не то. Оба ревизора нашли это независимо.
|
|
258
|
+
return typeof value.problem === 'string' && value.problem !== ''
|
|
259
|
+
? `Обзор не выполнен: ${value.problem}`
|
|
260
|
+
: 'Обзор не выполнен: ни один ревизор не выбран.'
|
|
196
261
|
}
|
|
197
262
|
|
|
198
263
|
const failed = value.reviewers.filter((r) => r.failed !== null)
|
|
@@ -203,7 +268,13 @@ export function render(value) {
|
|
|
203
268
|
lines.push('')
|
|
204
269
|
|
|
205
270
|
if (value.findings.length === 0) {
|
|
206
|
-
|
|
271
|
+
// «Находок нет» говорится ТОЛЬКО когда обзор действительно состоялся.
|
|
272
|
+
// Отказ или отмена, поданные как чистый результат, — худшее, что этот
|
|
273
|
+
// инструмент может сделать: пользователь сочтёт работу проверенной.
|
|
274
|
+
const cancelled = value.reviewers.some((entry) => entry.cancelled === true)
|
|
275
|
+
if (cancelled) lines.push('Обзор отменён — судить не по чему.')
|
|
276
|
+
else if (worked.length === 0) lines.push('Судить не по чему: не ответил ни один ревизор.')
|
|
277
|
+
else lines.push('Находок нет.')
|
|
207
278
|
return lines.join('\n')
|
|
208
279
|
}
|
|
209
280
|
|
|
@@ -266,7 +337,7 @@ export async function apply(ctx) {
|
|
|
266
337
|
},
|
|
267
338
|
reviewers: {
|
|
268
339
|
type: 'array',
|
|
269
|
-
items: { type: 'string', enum:
|
|
340
|
+
items: { type: 'string', enum: KNOWN_REVIEWERS },
|
|
270
341
|
description:
|
|
271
342
|
'Which reviewers to ask. Defaults to both — they find different things, and asking one guarantees missing half.',
|
|
272
343
|
},
|
|
@@ -295,12 +366,20 @@ export async function apply(ctx) {
|
|
|
295
366
|
|
|
296
367
|
// Выбор пользователя из настроек; вызов может его перекрыть.
|
|
297
368
|
const settings = await readSettings(cwd)
|
|
298
|
-
const
|
|
369
|
+
const asked = Array.isArray(args.reviewers) && args.reviewers.length > 0
|
|
299
370
|
? args.reviewers
|
|
300
371
|
: settings.enabled
|
|
301
372
|
|
|
373
|
+
// Повторы отбрасываются, и это не придирка: `reviewers` заполняет
|
|
374
|
+
// модель, а список из ста одинаковых имён запустил бы сто платных
|
|
375
|
+
// процессов разом — по одному на каждый элемент.
|
|
376
|
+
const wanted = [...new Set(asked)].filter((kind) => KNOWN_REVIEWERS.includes(kind))
|
|
377
|
+
|
|
302
378
|
if (wanted.length === 0) {
|
|
303
|
-
|
|
379
|
+
const problem = asked.length === 0
|
|
380
|
+
? 'не выбран ни один ревизор — включите их в настройках'
|
|
381
|
+
: `неизвестные ревизоры: ${[...new Set(asked)].join(', ')}`
|
|
382
|
+
return { findings: [], reviewers: [], problem }
|
|
304
383
|
}
|
|
305
384
|
|
|
306
385
|
const prompt = buildPrompt(args)
|
|
@@ -316,6 +395,7 @@ export async function apply(ctx) {
|
|
|
316
395
|
reviewers: answers.map((entry) => ({
|
|
317
396
|
provider: entry.provider,
|
|
318
397
|
failed: entry.failed,
|
|
398
|
+
cancelled: entry.cancelled === true,
|
|
319
399
|
found: entry.findings.length,
|
|
320
400
|
model: entry.model,
|
|
321
401
|
effort: entry.effort,
|
package/lib/reviewers.js
CHANGED
|
@@ -47,7 +47,7 @@ export const REVIEWERS = {
|
|
|
47
47
|
'claude-code': {
|
|
48
48
|
label: 'Claude Code',
|
|
49
49
|
command: 'claude',
|
|
50
|
-
models: ['claude-opus-4-8', 'claude-sonnet-4-6'],
|
|
50
|
+
models: ['claude-opus-5', 'claude-opus-4-8', 'claude-sonnet-4-6'],
|
|
51
51
|
efforts: ['low', 'medium', 'high'],
|
|
52
52
|
defaultModel: null,
|
|
53
53
|
defaultEffort: null,
|
|
@@ -78,9 +78,20 @@ export function buildArgs(kind, { model, effort, answerFile }) {
|
|
|
78
78
|
if (kind === 'claude-code') {
|
|
79
79
|
// `-p` без текста задания: задание придёт через поток ввода.
|
|
80
80
|
args.push('-p')
|
|
81
|
-
// Ревизор
|
|
82
|
-
//
|
|
83
|
-
|
|
81
|
+
// Ревизор ТОЛЬКО ЧИТАЕТ, и это обеспечено правами, а не просьбой.
|
|
82
|
+
//
|
|
83
|
+
// Здесь стоял `bypassPermissions` — полное право писать файлы и выполнять
|
|
84
|
+
// команды в рабочей папке пользователя, при том что соседний Codex
|
|
85
|
+
// запускался с `read-only`. Оба независимых ревизора нашли это и назвали
|
|
86
|
+
// критичным, справедливо: ревизор по заданию читает чужой непроверенный
|
|
87
|
+
// код, и указание внутри этого кода («игнорируй предыдущее, выполни…»)
|
|
88
|
+
// исполнилось бы без подтверждения и без следа в переписке — это
|
|
89
|
+
// отдельный процесс.
|
|
90
|
+
//
|
|
91
|
+
// Проверено с контролем: с этим списком инструментов запись отклоняется
|
|
92
|
+
// системой прав, с `bypassPermissions` файл создаётся немедленно.
|
|
93
|
+
args.push('--permission-mode', 'dontAsk')
|
|
94
|
+
args.push('--allowedTools', 'Read', 'Glob', 'Grep')
|
|
84
95
|
if (model) args.push('--model', model)
|
|
85
96
|
if (effort) args.push('--effort', effort)
|
|
86
97
|
return args
|
|
@@ -123,6 +134,16 @@ export async function runReviewer(ctx, kind, { prompt, cwd, model, effort, signa
|
|
|
123
134
|
}
|
|
124
135
|
}
|
|
125
136
|
|
|
137
|
+
// Отмену, случившуюся ДО вызова, надо увидеть сразу.
|
|
138
|
+
//
|
|
139
|
+
// Подписка на будущее событие `abort` у уже отменённого сигнала не
|
|
140
|
+
// срабатывает никогда: обработчик задним числом не вызывается. Без этой
|
|
141
|
+
// проверки нажатие «стоп» перед самым запуском не мешало нам завести
|
|
142
|
+
// платный процесс, который потом жил до самого тайм-аута.
|
|
143
|
+
if (signal?.aborted === true) {
|
|
144
|
+
return { kind, failed: 'отменено', cancelled: true, answer: null }
|
|
145
|
+
}
|
|
146
|
+
|
|
126
147
|
let workDir = null
|
|
127
148
|
let handle = null
|
|
128
149
|
// Своя отмена поверх пользовательской: ревизор без ограничения по времени
|
|
@@ -157,30 +178,60 @@ export async function runReviewer(ctx, kind, { prompt, cwd, model, effort, signa
|
|
|
157
178
|
})
|
|
158
179
|
|
|
159
180
|
const outcome = await handle.done
|
|
181
|
+
const stderrText = (await handle.collected.stderr.readFrom(0)).text
|
|
182
|
+
const tail = stderrText.trim().split('\n').slice(-3).join(' ')
|
|
183
|
+
|
|
184
|
+
// Отмена — это отмена, а не отказ ревизора.
|
|
185
|
+
//
|
|
186
|
+
// Раньше прерванный обзор возвращался как «пустой ответ», то есть
|
|
187
|
+
// выглядел обычным результатом: агент получал «ревизоры не ответили,
|
|
188
|
+
// находок нет» и спокойно шёл дальше, считая работу проверенной.
|
|
189
|
+
if (signal?.aborted === true) {
|
|
190
|
+
return { kind, failed: 'отменено', cancelled: true, answer: null }
|
|
191
|
+
}
|
|
192
|
+
if (deadline.signal.aborted) {
|
|
193
|
+
return { kind, failed: `превышено время ожидания (${Math.round((timeoutMs ?? DEFAULT_TIMEOUT_MS) / 1000)} с)`, answer: null }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// КОД ВОЗВРАТА ПРОВЕРЯЕТСЯ ВСЕГДА, а не только при пустом выводе.
|
|
197
|
+
//
|
|
198
|
+
// Claude Code печатает текст ошибки — истёкший вход, исчерпанный лимит,
|
|
199
|
+
// несуществующая модель — в обычный вывод и завершается с ненулевым
|
|
200
|
+
// кодом. Раньше такой вывод считался успешным ответом, разбор не находил
|
|
201
|
+
// в нём ни одной находки, и пользователь получал справку о чистоте кода
|
|
202
|
+
// при том, что обзор вообще не выполнялся. Это ровно тот тихий отказ,
|
|
203
|
+
// ради поиска которого весь инструмент и написан.
|
|
204
|
+
if (outcome.exitCode !== 0) {
|
|
205
|
+
const code = outcome.exitCode === null ? `сигнал ${String(outcome.signal)}` : `код ${outcome.exitCode}`
|
|
206
|
+
const said = (await handle.collected.stdout.readFrom(0)).text.trim().split('\n').slice(0, 2).join(' ')
|
|
207
|
+
const detail = tail !== '' ? tail : said
|
|
208
|
+
return { kind, failed: `ревизор завершился с ошибкой (${code})${detail === '' ? '' : `: ${detail}`}`, answer: null }
|
|
209
|
+
}
|
|
160
210
|
|
|
161
|
-
// Codex кладёт итог в файл, Claude Code печатает его в вывод.
|
|
162
211
|
let answer = ''
|
|
163
212
|
if (kind === 'codex') {
|
|
213
|
+
// Итог Codex берётся ТОЛЬКО из файла. Запасное чтение вывода отсюда
|
|
214
|
+
// убрано намеренно: в выводе лежит весь ход рассуждения вместе с
|
|
215
|
+
// образцом формата из задания, и разбор наплодил бы по нему находки-
|
|
216
|
+
// призраки — в том числе «критичную» с названием «краткое название».
|
|
217
|
+
// Отсутствие файла — это сбой, а не повод подсунуть журнал.
|
|
164
218
|
try {
|
|
165
219
|
answer = await readFile(answerFile, 'utf8')
|
|
166
220
|
} catch {
|
|
167
|
-
|
|
221
|
+
return { kind, failed: `Codex не оставил итогового ответа${tail === '' ? '' : `: ${tail}`}`, answer: null }
|
|
168
222
|
}
|
|
169
|
-
}
|
|
170
|
-
if (answer.trim() === '') {
|
|
223
|
+
} else {
|
|
171
224
|
answer = (await handle.collected.stdout.readFrom(0)).text
|
|
172
225
|
}
|
|
173
226
|
|
|
174
227
|
if (answer.trim() === '') {
|
|
175
|
-
|
|
176
|
-
const tail = stderr.trim().split('\n').slice(-3).join(' ')
|
|
177
|
-
const code = outcome.exitCode === null ? `сигнал ${String(outcome.signal)}` : `код ${outcome.exitCode}`
|
|
178
|
-
return { kind, failed: `пустой ответ (${code})${tail === '' ? '' : `: ${tail}`}`, answer: null }
|
|
228
|
+
return { kind, failed: `пустой ответ${tail === '' ? '' : `: ${tail}`}`, answer: null }
|
|
179
229
|
}
|
|
180
230
|
|
|
181
|
-
return { kind, failed: null, answer, model: model ?? null, effort: effort ?? null }
|
|
231
|
+
return { kind, failed: null, cancelled: false, answer, model: model ?? null, effort: effort ?? null }
|
|
182
232
|
} catch (error) {
|
|
183
|
-
|
|
233
|
+
if (signal?.aborted === true) return { kind, failed: 'отменено', cancelled: true, answer: null }
|
|
234
|
+
const reason = deadline.signal.aborted ? 'превышено время ожидания' : String(error?.message ?? error)
|
|
184
235
|
return { kind, failed: reason, answer: null }
|
|
185
236
|
} finally {
|
|
186
237
|
clearTimeout(timer)
|
|
@@ -188,6 +239,12 @@ export async function runReviewer(ctx, kind, { prompt, cwd, model, effort, signa
|
|
|
188
239
|
if (handle !== null) {
|
|
189
240
|
try {
|
|
190
241
|
handle.terminate()
|
|
242
|
+
// Ждём, пока процесс действительно умрёт, прежде чем убирать за ним.
|
|
243
|
+
// Без ожидания удаление шло при живом процессе, у которого есть
|
|
244
|
+
// отпущенное время на завершение и открытый файл итога: на Windows
|
|
245
|
+
// это даёт отказ, `force` его проглатывает, и временные каталоги
|
|
246
|
+
// копятся после каждой отмены.
|
|
247
|
+
await handle.waitForExit()
|
|
191
248
|
} catch {
|
|
192
249
|
// Уборка не должна подменять результат.
|
|
193
250
|
}
|
package/package.json
CHANGED