@codedology/code-forge 0.7.2 → 0.7.4
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 +20 -0
- package/README.md +15 -0
- package/docs/reference/blocks.json +33 -1
- package/package.json +1 -1
- package/skill/references/review.md +2 -2
- package/skill/scripts/forge +1 -1
- package/src/autopilot/binnacle.mjs +29 -1
- package/src/review/engine.mjs +135 -33
- package/src/review/fixloop.mjs +146 -33
- package/src/review/moved.mjs +399 -0
- package/src/review/packet.mjs +32 -23
- package/src/review/schema-fallback.mjs +351 -0
- package/src/review/sections.mjs +5 -4
- package/src/review/validate-review.mjs +11 -0
- package/src/session/spawn.mjs +3 -86
- package/src/util/diagnostic-tail.mjs +95 -0
- package/src/worker/engine.mjs +78 -8
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The second-level retry after repeated `schema` misses on ONE packet (block B55, issue #2).
|
|
3
|
+
*
|
|
4
|
+
* Field data: one L2 reviewer model answered `unavailable: schema` four times in a row on the same
|
|
5
|
+
* packet (round 1, its retry, a patch_check recheck, a fresh review), while the model configured in
|
|
6
|
+
* `review.second_levels.L2` reviewed that packet cleanly on its first try.
|
|
7
|
+
*
|
|
8
|
+
* What counts as "2 schema failures on the same packet": a stub-guard verdict `schema` from a
|
|
9
|
+
* session of the level's OWN ladder (never one from a second-level try) on the same packet — the
|
|
10
|
+
* same run, file, content hash and exact packet text (`packet_hash`, sha256 of the text) — at the
|
|
11
|
+
* same level and role (one level's misses or mark never count for, or block, another's). Every
|
|
12
|
+
* such miss leaves one signed `review.schema_invalid` row; the count is those rows (read through
|
|
13
|
+
* `history`, which only counts rows whose MAC verifies) plus the miss in hand. In practice: the
|
|
14
|
+
* round's first session is miss 1; the worker's existing retry of that round (`next: retry`, the
|
|
15
|
+
* next ticket re-runs the SAME packet) is miss 2, and that ticket then tries the second level once.
|
|
16
|
+
* A session that times out is retried first by `session-retry.mjs` and counts once, with its last
|
|
17
|
+
* result; a `needs_file` round or a Markdown section is another packet text, so another count, and
|
|
18
|
+
* so is a recheck whose pending set changed between tries (S1 `resolved` closed another finding,
|
|
19
|
+
* so the open-findings list differs): a different packet starts its own count, by design.
|
|
20
|
+
*
|
|
21
|
+
* The rows written here carry no `run`, `file` or `content_hash`: the worker's `writeRow` stamps
|
|
22
|
+
* them (`worker/loop.mjs`: `{...row, block, file, content_hash: ticket.content_hash}`, signed with
|
|
23
|
+
* the run key), and the worker's history reads them back by the same ticket values — so outside
|
|
24
|
+
* the worker (no history) nothing is ever tried.
|
|
25
|
+
*
|
|
26
|
+
* At most ONCE per packet: a signed `review.schema_fallback` row is written BEFORE the second level
|
|
27
|
+
* is spawned, and any such row for the packet (whatever its outcome) stops every later try; when
|
|
28
|
+
* that row cannot be written nothing is spawned. The second level is `review.second_levels.<level>`
|
|
29
|
+
* only — nothing is tried without one; when every model of it is the one that just failed, the
|
|
30
|
+
* mark says `refused: same-model` and nothing is spawned. The try goes through the same `spawn` as
|
|
31
|
+
* any session, so the budget gate (`budget.usd`, B33) refuses it like any other (its own
|
|
32
|
+
* `budget.refused` row; the review is `unavailable: budget`). For a closed-book role every Codex
|
|
33
|
+
* model of the second level (its own and its ladder) is dropped unless
|
|
34
|
+
* `review.allow_open_book_codex`; when none is left the mark says `refused: closed-book` and
|
|
35
|
+
* nothing is spawned — the spawner's own refusal stays as it was. Its answer goes
|
|
36
|
+
* through the same stub guard; a valid one is a normal answer, a failed one leaves the file
|
|
37
|
+
* unavailable as before.
|
|
38
|
+
*
|
|
39
|
+
* Every schema miss (second-level ones included) is logged as `review.schema_invalid {lens, role,
|
|
40
|
+
* level, provider, model, fallback_step, packet_hash, second_level, ...diagnosis}` — a STRUCTURAL
|
|
41
|
+
* diagnosis of the answer, never its text ({@link diagnoseAnswer}): its size and kind, for JSON the
|
|
42
|
+
* top-level keys with their JSON types and the finding-schema errors (path, keyword, a few
|
|
43
|
+
* schema-side params, Ajv's message), for non-JSON the kind of parse error and its byte offset.
|
|
44
|
+
* Text a model copies from the packet into a JSON string can never reach the ledger this way.
|
|
45
|
+
*
|
|
46
|
+
* Concurrency: the worker never runs two tickets for one (block, file) at once (its file lock,
|
|
47
|
+
* `worker/loop.mjs`), and round 1, its retry, every recheck and the patch_check of a file are all
|
|
48
|
+
* tickets for that file, so one packet's history is read and written by one ticket at a time. The
|
|
49
|
+
* read-history → write-mark section here also runs under an in-process lock per (packet hash,
|
|
50
|
+
* level, role), so even the same file in two blocks cannot take a packet's one try twice.
|
|
51
|
+
*
|
|
52
|
+
* No ledger writer (`writeRow` absent) ⇒ nothing is logged and nothing is tried. The helpers never
|
|
53
|
+
* throw: a history that cannot be read disables the try (with one warning line), and a
|
|
54
|
+
* second-level spawn that throws is a session with no result (`exit`) after its mark row.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
import { createHash } from 'node:crypto';
|
|
58
|
+
import { closedBookRefused, openBookCodexAllowed } from '../config/closed-book.mjs';
|
|
59
|
+
import { resolveLevel } from '../config/known-ids.mjs';
|
|
60
|
+
import { logWarning } from '../util/error-log.mjs';
|
|
61
|
+
import { keyedLock } from '../util/locks.mjs';
|
|
62
|
+
import { schemaErrorsOf } from './validate-review.mjs';
|
|
63
|
+
|
|
64
|
+
/** Schema misses of the level's own ladder on one packet before the second level is tried. */
|
|
65
|
+
export const SCHEMA_FAILURES_BEFORE_FALLBACK = 2;
|
|
66
|
+
|
|
67
|
+
/** At most this many schema errors are kept in a diagnosis (`errors_total` has the full count). */
|
|
68
|
+
export const DIAGNOSIS_MAX_ERRORS = 20;
|
|
69
|
+
/** At most this many top-level keys are kept in a diagnosis (the rest counted in `other_keys`). */
|
|
70
|
+
export const DIAGNOSIS_MAX_KEYS = 30;
|
|
71
|
+
/** Ajv's message, and a parse error, are kept up to this length. */
|
|
72
|
+
const MESSAGE_MAX = 120;
|
|
73
|
+
/** A key or path segment is shown only when it is this plain; any other is `*` (it could be content). */
|
|
74
|
+
const PLAIN_KEY = /^[A-Za-z0-9_.-]{1,40}$/;
|
|
75
|
+
/** The Ajv params kept: they come from the schema, never from the answer's data. */
|
|
76
|
+
const KEPT_PARAMS = Object.freeze(['missingProperty', 'additionalProperty', 'type', 'limit', 'comparison', 'format']);
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @typedef {(packetHash: string, who: {level: string, role: string}) => Promise<{failures: number, fellBack: boolean}>} SchemaHistory -
|
|
80
|
+
* the signed history of ONE packet (`packetHash`) at one level and role in this run for this
|
|
81
|
+
* file at this content hash: `failures` = `review.schema_invalid` rows of the level's own ladder
|
|
82
|
+
* (`second_level` not true), `fellBack` = a `review.schema_fallback` row exists. Built by the
|
|
83
|
+
* worker; absent ⇒ no second-level try.
|
|
84
|
+
*/
|
|
85
|
+
|
|
86
|
+
/** @param {string} text @returns {string} the packet's identity: sha256 hex of its exact text. */
|
|
87
|
+
export function packetHash(text) {
|
|
88
|
+
return createHash('sha256').update(text, 'utf8').digest('hex');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** @param {unknown} v @returns {string} the JSON type of `v`. */
|
|
92
|
+
function jsonType(v) {
|
|
93
|
+
if (v === null) return 'null';
|
|
94
|
+
if (Array.isArray(v)) return 'array';
|
|
95
|
+
if (typeof v === 'number') return Number.isInteger(v) ? 'integer' : 'number';
|
|
96
|
+
return typeof v;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** @param {unknown} key @returns {string} the key when plain (or an array index), else `*`. */
|
|
100
|
+
function plainKey(key) {
|
|
101
|
+
if (typeof key === 'number') return String(key);
|
|
102
|
+
return typeof key === 'string' && PLAIN_KEY.test(key) ? key : '*';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** @param {string} pointer @returns {string} the JSON pointer with every non-plain segment as `*`. */
|
|
106
|
+
function plainPath(pointer) {
|
|
107
|
+
if (pointer === '') return '';
|
|
108
|
+
const segments = pointer.slice(1).split('/').map((seg) => (/^\d+$/.test(seg) ? seg : plainKey(seg.replace(/~1/g, '/').replace(/~0/g, '~'))));
|
|
109
|
+
return `/${segments.join('/')}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A parse error without any of the text: V8 quotes the input after a `"` and names the bad
|
|
114
|
+
* character, so the message is cut at its first quote, the token and the line/column dropped.
|
|
115
|
+
* @param {string} message @returns {string}
|
|
116
|
+
*/
|
|
117
|
+
function plainParseError(message) {
|
|
118
|
+
const cut = message
|
|
119
|
+
.split('"')[0]
|
|
120
|
+
.replace(/Unexpected token '.*?'/, 'Unexpected token')
|
|
121
|
+
.replace(/\s*\(line \d+ column \d+\)/, '')
|
|
122
|
+
.replace(/[,\s]+$/, '');
|
|
123
|
+
return cut.slice(0, MESSAGE_MAX);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The structural diagnosis of a schema miss (B55) — never the answer's text: `answer_bytes`,
|
|
128
|
+
* `answer_kind` (`json`, `not-json`, `empty`); for JSON `top_type`, `top_keys` (plain keys ⇒ their
|
|
129
|
+
* JSON type, at most {@link DIAGNOSIS_MAX_KEYS}; any other key only counted in `other_keys`),
|
|
130
|
+
* `schema_errors` (at most {@link DIAGNOSIS_MAX_ERRORS}, from the stub guard's own validator:
|
|
131
|
+
* `path` with non-plain segments as `*`, `keyword`, the schema-side `params`, Ajv's `message` ≤ 120
|
|
132
|
+
* chars) and `errors_total`; for non-JSON `parse_error` (V8's message cut before any quoted input)
|
|
133
|
+
* and `parse_offset` (bytes; null when V8 gives no position).
|
|
134
|
+
* @param {Record<string, any> | null} res - the session result (`text`, else `answer`).
|
|
135
|
+
* @returns {Record<string, any>}
|
|
136
|
+
*/
|
|
137
|
+
export function diagnoseAnswer(res) {
|
|
138
|
+
const text = typeof res?.text === 'string' && res.text.length > 0 ? res.text : typeof res?.answer === 'string' ? res.answer : '';
|
|
139
|
+
const answer_bytes = Buffer.byteLength(text);
|
|
140
|
+
/** @type {unknown} */
|
|
141
|
+
let value = res?.answer !== undefined && res?.answer !== null && typeof res.answer !== 'string' ? res.answer : undefined;
|
|
142
|
+
if (value === undefined) {
|
|
143
|
+
if (text.trim().length === 0) return { answer_bytes, answer_kind: 'empty' };
|
|
144
|
+
try {
|
|
145
|
+
value = JSON.parse(text);
|
|
146
|
+
} catch (err) {
|
|
147
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
148
|
+
const at = /at position (\d+)/.exec(message);
|
|
149
|
+
const parse_offset = at ? Buffer.byteLength(text.slice(0, Number(at[1]))) : /end of JSON input/.test(message) ? answer_bytes : null;
|
|
150
|
+
return { answer_bytes, answer_kind: 'not-json', parse_error: plainParseError(message), parse_offset };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/** @type {Record<string, string>} */
|
|
154
|
+
const top_keys = {};
|
|
155
|
+
let other_keys = 0;
|
|
156
|
+
if (jsonType(value) === 'object') {
|
|
157
|
+
for (const [k, v] of Object.entries(/** @type {Record<string, unknown>} */ (value))) {
|
|
158
|
+
if (PLAIN_KEY.test(k) && k !== '__proto__' && Object.keys(top_keys).length < DIAGNOSIS_MAX_KEYS) top_keys[k] = jsonType(v);
|
|
159
|
+
else other_keys += 1;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const errors = schemaErrorsOf(value);
|
|
163
|
+
const schema_errors = errors.slice(0, DIAGNOSIS_MAX_ERRORS).map((e) => {
|
|
164
|
+
/** @type {Record<string, unknown>} */
|
|
165
|
+
const given = e.params ?? {};
|
|
166
|
+
/** @type {Record<string, unknown>} */
|
|
167
|
+
const params = {};
|
|
168
|
+
for (const name of KEPT_PARAMS) {
|
|
169
|
+
const v = given[name];
|
|
170
|
+
if (v === undefined) continue;
|
|
171
|
+
if (name === 'missingProperty' || name === 'additionalProperty') params[name] = plainKey(v);
|
|
172
|
+
else if (typeof v === 'number' || typeof v === 'string') params[name] = v;
|
|
173
|
+
else if (Array.isArray(v)) params[name] = v.filter((x) => typeof x === 'string');
|
|
174
|
+
}
|
|
175
|
+
return { path: plainPath(e.instancePath ?? ''), keyword: e.keyword, params, message: String(e.message ?? '').slice(0, MESSAGE_MAX) };
|
|
176
|
+
});
|
|
177
|
+
return { answer_bytes, answer_kind: 'json', top_type: jsonType(value), top_keys, other_keys, schema_errors, errors_total: errors.length };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The config a second-level try runs with: `levels.<level>` replaced by
|
|
182
|
+
* `review.second_levels.<level>`. Every model of that level — its own, then its fallback ladder —
|
|
183
|
+
* gets its effective provider (the entry's own, else the level's: its `provider`, else
|
|
184
|
+
* `review.second_provider`, else the top-level one), and every one equal to the model that just
|
|
185
|
+
* failed is dropped; for a closed-book `role` every model Codex cannot run closed-book is dropped
|
|
186
|
+
* too (unless `review.allow_open_book_codex`). The first one left runs, the rest are its ladder.
|
|
187
|
+
* `ok: false` with `no-second-level` when none is configured for the level, `same-model` when
|
|
188
|
+
* nothing but the failed model is configured, `closed-book` when only refused models are left.
|
|
189
|
+
* @param {Record<string, any>} cfg @param {string} level
|
|
190
|
+
* @param {{provider: string | null, model: string | null}} from - the model that missed.
|
|
191
|
+
* @param {string} [role] - the session's role (closed-book check); absent ⇒ no such check.
|
|
192
|
+
* @returns {{ok: true, cfg: Record<string, any>, provider: string, model: string} | {ok: false, reason: 'no-second-level' | 'same-model' | 'closed-book'}}
|
|
193
|
+
*/
|
|
194
|
+
export function secondLevelFor(cfg, level, from, role) {
|
|
195
|
+
const override = cfg?.review?.second_levels?.[level] ?? null;
|
|
196
|
+
if (!override || typeof override !== 'object' || typeof override.model !== 'string' || override.model.length === 0) return { ok: false, reason: 'no-second-level' };
|
|
197
|
+
const levelProvider = override.provider ?? cfg?.review?.second_provider ?? cfg?.provider;
|
|
198
|
+
if (typeof levelProvider !== 'string' || levelProvider.length === 0) return { ok: false, reason: 'no-second-level' };
|
|
199
|
+
const { fallback: ladder, effort: _effort, ...own } = override;
|
|
200
|
+
/** @type {Array<{provider: string, model: string, effort?: string}>} */
|
|
201
|
+
const models = [override, ...(Array.isArray(ladder) ? ladder : [])]
|
|
202
|
+
.filter((e) => e && typeof e === 'object' && typeof e.model === 'string' && e.model.length > 0)
|
|
203
|
+
.map((e) => ({ provider: typeof e.provider === 'string' && e.provider.length > 0 ? e.provider : levelProvider, model: e.model, ...(typeof e.effort === 'string' ? { effort: e.effort } : {}) }))
|
|
204
|
+
.filter((e) => !(e.provider === from.provider && e.model === from.model));
|
|
205
|
+
if (models.length === 0) return { ok: false, reason: 'same-model' };
|
|
206
|
+
const openBook = openBookCodexAllowed(cfg);
|
|
207
|
+
const runnable = role === undefined ? models : models.filter((e) => !closedBookRefused(e.provider, role, openBook));
|
|
208
|
+
if (runnable.length === 0) return { ok: false, reason: 'closed-book' };
|
|
209
|
+
const [first, ...rest] = runnable;
|
|
210
|
+
const entry = { ...own, provider: first.provider, model: first.model, ...(first.effort ? { effort: first.effort } : {}), fallback: rest };
|
|
211
|
+
const next = { ...cfg, levels: { ...cfg.levels, [level]: entry } };
|
|
212
|
+
try {
|
|
213
|
+
resolveLevel(next, /** @type {any} */ (level));
|
|
214
|
+
} catch {
|
|
215
|
+
return { ok: false, reason: 'no-second-level' };
|
|
216
|
+
}
|
|
217
|
+
return { ok: true, cfg: next, provider: first.provider, model: first.model };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Log a schema miss and, when it is the packet's second (see the module doc), try the second level
|
|
222
|
+
* once. Never throws (see the module doc).
|
|
223
|
+
* @param {object} p
|
|
224
|
+
* @param {Record<string, any> | null} p.res - the missed session's result.
|
|
225
|
+
* @param {{lens: string, role: string, level: string}} p.session
|
|
226
|
+
* @param {string} p.packetText - the exact packet the session read (still on disk for `spawnAt`).
|
|
227
|
+
* @param {Record<string, any>} p.cfg - the config the missed session ran with.
|
|
228
|
+
* @param {boolean} p.allowed - false where a second-level try is never made (consensus mode).
|
|
229
|
+
* @param {SchemaHistory | undefined} p.history
|
|
230
|
+
* @param {((row: Record<string, any>) => Promise<unknown>) | undefined} p.writeRow - throws on a
|
|
231
|
+
* failed write; absent ⇒ nothing is logged or tried.
|
|
232
|
+
* @param {(cfg: Record<string, any>) => Promise<{res: Record<string, any> | null, attempts: number}>} p.spawnAt
|
|
233
|
+
* - the same session on the same packet with another config (timeout retry included).
|
|
234
|
+
* @returns {Promise<{res: Record<string, any> | null, attempts: number, to: {level: string, provider: string, model: string}} | null>}
|
|
235
|
+
* the second level's result, or null when none was spawned.
|
|
236
|
+
*/
|
|
237
|
+
export async function afterSchemaMiss({ res, session, packetText, cfg, allowed, history, writeRow, spawnAt }) {
|
|
238
|
+
if (!writeRow) return null;
|
|
239
|
+
const write = writeRow;
|
|
240
|
+
const hash = packetHash(packetText);
|
|
241
|
+
const from = { provider: typeof res?.provider === 'string' ? res.provider : null, model: typeof res?.model === 'string' ? res.model : null };
|
|
242
|
+
/** @type {{cfg: Record<string, any>, to: {level: string, provider: string, model: string}} | null} */
|
|
243
|
+
let go = null;
|
|
244
|
+
try {
|
|
245
|
+
go = await keyedLock(`schema-fallback\0${hash}\0${session.level}\0${session.role}`, async () => {
|
|
246
|
+
// the history is read BEFORE this miss is logged: failures = signed earlier misses + this one
|
|
247
|
+
let seen = null;
|
|
248
|
+
if (allowed && history) {
|
|
249
|
+
try {
|
|
250
|
+
seen = await history(hash, { level: session.level, role: session.role });
|
|
251
|
+
} catch {
|
|
252
|
+
seen = null; // unreadable history ⇒ no try (fail closed), one warning line
|
|
253
|
+
await logWarning({ warning: 'review_schema_history', message: 'the schema-miss history could not be read; no second-level try' }).catch(() => null);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
try {
|
|
257
|
+
await write({ event: 'review.schema_invalid', lens: session.lens, role: session.role, level: session.level, ...from, fallback_step: res?.fallback_step ?? 0, packet_hash: hash, second_level: false, ...diagnoseAnswer(res) });
|
|
258
|
+
} catch {
|
|
259
|
+
// the log is best-effort; the count then misses this one (fewer tries, never more)
|
|
260
|
+
}
|
|
261
|
+
if (!seen || seen.fellBack || seen.failures + 1 < SCHEMA_FAILURES_BEFORE_FALLBACK) return null;
|
|
262
|
+
const second = secondLevelFor(cfg, session.level, from, session.role);
|
|
263
|
+
const to = `review.second_levels.${session.level}`;
|
|
264
|
+
/** @type {string | null} */
|
|
265
|
+
let refused = null;
|
|
266
|
+
if (second.ok === false) {
|
|
267
|
+
if (second.reason === 'no-second-level') return null;
|
|
268
|
+
refused = second.reason; // `same-model` or `closed-book`: nothing of the level may run
|
|
269
|
+
}
|
|
270
|
+
await write({
|
|
271
|
+
event: 'review.schema_fallback',
|
|
272
|
+
lens: session.lens,
|
|
273
|
+
role: session.role,
|
|
274
|
+
level: session.level,
|
|
275
|
+
packet_hash: hash,
|
|
276
|
+
from_provider: from.provider,
|
|
277
|
+
from_model: from.model,
|
|
278
|
+
to,
|
|
279
|
+
to_provider: second.ok ? second.provider : null,
|
|
280
|
+
to_model: second.ok ? second.model : null,
|
|
281
|
+
attempts: seen.failures + 1,
|
|
282
|
+
...(refused ? { refused } : {}),
|
|
283
|
+
}); // the once-per-packet mark: a failed write throws ⇒ no try
|
|
284
|
+
return !second.ok ? null : { cfg: second.cfg, to: { level: to, provider: second.provider, model: second.model } };
|
|
285
|
+
});
|
|
286
|
+
} catch {
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
if (!go) return null;
|
|
290
|
+
await logWarning({ warning: 'review_schema_fallback', message: 'a review answer failed the schema twice on one packet; the second level was tried once' }).catch(() => null);
|
|
291
|
+
try {
|
|
292
|
+
const out = await spawnAt(go.cfg);
|
|
293
|
+
return { res: out.res, attempts: out.attempts, to: go.to };
|
|
294
|
+
} catch {
|
|
295
|
+
// a throwing spawn is a session with no result: `exit` through the stub guard, never approval
|
|
296
|
+
return { res: null, attempts: 1, to: go.to };
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Log a schema miss of the second-level try itself (never counted, never tried again).
|
|
302
|
+
* @param {{res: Record<string, any> | null, session: {lens: string, role: string, level: string}, packetText: string, writeRow: ((row: Record<string, any>) => Promise<unknown>) | undefined}} p
|
|
303
|
+
*/
|
|
304
|
+
export async function logSecondLevelMiss({ res, session, packetText, writeRow }) {
|
|
305
|
+
if (!writeRow) return;
|
|
306
|
+
try {
|
|
307
|
+
await writeRow({
|
|
308
|
+
event: 'review.schema_invalid',
|
|
309
|
+
lens: session.lens,
|
|
310
|
+
role: session.role,
|
|
311
|
+
level: session.level,
|
|
312
|
+
provider: typeof res?.provider === 'string' ? res.provider : null,
|
|
313
|
+
model: typeof res?.model === 'string' ? res.model : null,
|
|
314
|
+
fallback_step: res?.fallback_step ?? 0,
|
|
315
|
+
packet_hash: packetHash(packetText),
|
|
316
|
+
second_level: true,
|
|
317
|
+
...diagnoseAnswer(res),
|
|
318
|
+
});
|
|
319
|
+
} catch {
|
|
320
|
+
// best-effort
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* The worker's {@link SchemaHistory} over the run's ledger: for the packet, level and role asked
|
|
326
|
+
* about, rows of `runId` for `file` at `contentHash` with that `packet_hash`, `level` and `role`
|
|
327
|
+
* whose MAC verifies (`verify`; a row that does not verify, or whose check throws, counts for
|
|
328
|
+
* nothing). A miss on another packet of the same content, or at another level, never counts.
|
|
329
|
+
* @param {{readRows: () => Promise<Array<Record<string, any>>>, verify: (row: Record<string, any>) => boolean, runId: string, file: string, contentHash: string}} opts
|
|
330
|
+
* @returns {SchemaHistory}
|
|
331
|
+
*/
|
|
332
|
+
export function ledgerSchemaHistory({ readRows, verify, runId, file, contentHash }) {
|
|
333
|
+
return async (hash, who) => {
|
|
334
|
+
const rows = (await readRows()).filter((r) => r?.run === runId && r.file === file && r.content_hash === contentHash && r.packet_hash === hash && r.level === who.level && r.role === who.role);
|
|
335
|
+
let failures = 0;
|
|
336
|
+
let fellBack = false;
|
|
337
|
+
for (const r of rows) {
|
|
338
|
+
if (r.event !== 'review.schema_invalid' && r.event !== 'review.schema_fallback') continue;
|
|
339
|
+
let ok = false;
|
|
340
|
+
try {
|
|
341
|
+
ok = verify(r);
|
|
342
|
+
} catch {
|
|
343
|
+
ok = false;
|
|
344
|
+
}
|
|
345
|
+
if (!ok) continue;
|
|
346
|
+
if (r.event === 'review.schema_fallback') fellBack = true;
|
|
347
|
+
else if (r.second_level !== true) failures += 1;
|
|
348
|
+
}
|
|
349
|
+
return { failures, fellBack };
|
|
350
|
+
};
|
|
351
|
+
}
|
package/src/review/sections.mjs
CHANGED
|
@@ -203,14 +203,15 @@ function diffOf(diff, fileHeader, pieces) {
|
|
|
203
203
|
* Pack the heading groups into section packets within `budget` (step 3 of the module doc). A
|
|
204
204
|
* candidate's size is `measure(its diff, its groups)` (a recheck adds the open findings it lists
|
|
205
205
|
* for those groups). Never `ok` with no section: a diff with no hunk, or one whose hunks do not
|
|
206
|
-
* parse one to one, is `split_required` at `measure(diff,
|
|
207
|
-
* @param {{diff: FileDiff, budget: number, measure: (diff: FileDiff, groups: SectionGroup[]) => number}} opts -
|
|
208
|
-
* `measure`: the packet tokens of a candidate (the largest over the session lenses)
|
|
206
|
+
* parse one to one, is `split_required` at `measure(diff, null)` with `section: null`.
|
|
207
|
+
* @param {{diff: FileDiff, budget: number, measure: (diff: FileDiff, groups: SectionGroup[] | null) => number}} opts -
|
|
208
|
+
* `measure`: the packet tokens of a candidate (the largest over the session lenses); `groups`
|
|
209
|
+
* null ⇒ the whole file (B56: its whole moved-code section), never an empty section.
|
|
209
210
|
* @returns {{status: 'ok', sections: Section[]} | {status: 'split_required', tokensIn: number, budget: number, section: string | null}}
|
|
210
211
|
*/
|
|
211
212
|
export function packSections({ diff, budget, measure }) {
|
|
212
213
|
const groups = sectionGroups(diff);
|
|
213
|
-
if (groups === null || groups.length === 0) return { status: 'split_required', tokensIn: measure(diff,
|
|
214
|
+
if (groups === null || groups.length === 0) return { status: 'split_required', tokensIn: measure(diff, null), budget, section: null };
|
|
214
215
|
/** @param {SectionGroup[]} set */
|
|
215
216
|
const size = (set) => measure(merge(diff, set), set);
|
|
216
217
|
/** @type {Section[]} */
|
|
@@ -37,6 +37,17 @@ const validateSchema = (() => {
|
|
|
37
37
|
return new Ajv2020({ allErrors: true, strict: false }).compile(body);
|
|
38
38
|
})();
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* The finding-schema errors of `value` from the SAME validator the stub guard uses (B55): Ajv's
|
|
42
|
+
* raw error objects, every one (`allErrors`). Callers must strip anything that can carry the
|
|
43
|
+
* answer's content (`review/schema-fallback.mjs`); this returns them as Ajv gives them.
|
|
44
|
+
* @param {unknown} value @returns {Array<import('ajv').ErrorObject>} empty when it validates.
|
|
45
|
+
*/
|
|
46
|
+
export function schemaErrorsOf(value) {
|
|
47
|
+
if (validateSchema(value) === true) return [];
|
|
48
|
+
return [...(validateSchema.errors ?? [])];
|
|
49
|
+
}
|
|
50
|
+
|
|
40
51
|
/** @param {Record<string, any> | undefined} cfg @returns {number} */
|
|
41
52
|
export function minTokensOut(cfg) {
|
|
42
53
|
const v = cfg?.review?.min_tokens_out;
|
package/src/session/spawn.mjs
CHANGED
|
@@ -64,6 +64,7 @@ import { exec } from '../util/exec.mjs';
|
|
|
64
64
|
import { isForbidden, mergeForbidden } from '../util/forbidden.mjs';
|
|
65
65
|
import { semaphore } from '../util/locks.mjs';
|
|
66
66
|
import { readStartTime, registerPid, UNKNOWN_START_TIME } from '../util/reaper.mjs';
|
|
67
|
+
import { asText, stderrTail } from '../util/diagnostic-tail.mjs';
|
|
67
68
|
import { redact, writeSafe } from '../util/redact.mjs';
|
|
68
69
|
import { currentRunRoot, pidsDir, runRoot, tmpBase, untrustedReason } from '../util/tmp.mjs';
|
|
69
70
|
|
|
@@ -152,11 +153,8 @@ export function runRootFor(runId, root) {
|
|
|
152
153
|
return runRoot(runId, { root });
|
|
153
154
|
}
|
|
154
155
|
|
|
155
|
-
|
|
156
|
-
export
|
|
157
|
-
|
|
158
|
-
/** A timed-out session's stderr over this size keeps no text at all (bounds the redact cost). */
|
|
159
|
-
export const STDERR_MAX_BYTES = 1024 * 1024;
|
|
156
|
+
// B30's stderr tail moved to `util/diagnostic-tail.mjs` (B55 shares it); re-exported here unchanged
|
|
157
|
+
export { DIAGNOSTIC_TAIL_BYTES, STDERR_MAX_BYTES, TAIL_WITHHELD_LARGE, TAIL_WITHHELD_LONG_LINE, TAIL_WITHHELD_PACKET, stderrTail } from '../util/diagnostic-tail.mjs';
|
|
160
158
|
|
|
161
159
|
/** How many stdout event types a timed-out session keeps. */
|
|
162
160
|
export const STDOUT_EVENTS_MAX = 20;
|
|
@@ -164,87 +162,6 @@ export const STDOUT_EVENTS_MAX = 20;
|
|
|
164
162
|
/** The longest event type kept. */
|
|
165
163
|
const EVENT_TYPE_MAX = 40;
|
|
166
164
|
|
|
167
|
-
/**
|
|
168
|
-
* Packet lines shorter than this are too common to tell apart from ordinary output, so they are
|
|
169
|
-
* NOT filtered. Accepted risk (B30): a packet line under 16 characters (a brace, `return x;`) can
|
|
170
|
-
* appear in a stderr tail; such a line carries no meaningful packet content on its own.
|
|
171
|
-
*/
|
|
172
|
-
const PACKET_LINE_MIN = 16;
|
|
173
|
-
|
|
174
|
-
/** The tail note when the packet cannot be read: no stderr text is kept at all (fail closed). */
|
|
175
|
-
export const TAIL_WITHHELD_PACKET = 'withheld: packet unreadable';
|
|
176
|
-
/** The tail note when stderr is over `STDERR_MAX_BYTES`. */
|
|
177
|
-
export const TAIL_WITHHELD_LARGE = 'stderr too large, withheld';
|
|
178
|
-
/** The tail note when the last 4 KB of stderr is one partial line. */
|
|
179
|
-
export const TAIL_WITHHELD_LONG_LINE = 'one long line, withheld';
|
|
180
|
-
|
|
181
|
-
/** @param {Buffer} buf @param {number} at @returns {number} the first character start at or after `at`. */
|
|
182
|
-
function charStart(buf, at) {
|
|
183
|
-
let i = at;
|
|
184
|
-
while (i < buf.length && (buf[i] & 0xc0) === 0x80) i += 1;
|
|
185
|
-
return i;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/** @param {unknown} text @returns {string} */
|
|
189
|
-
function asText(text) {
|
|
190
|
-
return Buffer.isBuffer(text) ? text.toString('utf8') : typeof text === 'string' ? text : '';
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
const PEM_BEGIN = /-----BEGIN [A-Z0-9 ]*-----/;
|
|
194
|
-
const PEM_END = /-----END [A-Z0-9 ]*-----/;
|
|
195
|
-
|
|
196
|
-
/**
|
|
197
|
-
* The stderr tail of a timed-out session (B30), never packet content:
|
|
198
|
-
* 1. no packet text (unreadable) ⇒ no text: `{tail: null, note: 'withheld: packet unreadable'}`;
|
|
199
|
-
* stderr over `STDERR_MAX_BYTES` ⇒ `{tail: null, note: 'stderr too large, withheld'}`;
|
|
200
|
-
* 2. on the FULL raw text: every PEM block (`-----BEGIN` … `-----END` lines, an unterminated one
|
|
201
|
-
* to the end) and every line repeating a packet line (≥ 16 chars) is dropped;
|
|
202
|
-
* 3. the FULL remaining text goes through B0 `redact`;
|
|
203
|
-
* 4. over `maxBytes`, the last `maxBytes` bytes are kept, starting on a character, with the first
|
|
204
|
-
* (partial) line dropped; when that piece has no line break the tail is withheld.
|
|
205
|
-
* @param {string | Buffer | null | undefined} text @param {string | null} packetText @param {number} [maxBytes]
|
|
206
|
-
* @returns {{tail: string | null, note?: string}}
|
|
207
|
-
*/
|
|
208
|
-
export function stderrTail(text, packetText, maxBytes = DIAGNOSTIC_TAIL_BYTES) {
|
|
209
|
-
if (typeof packetText !== 'string') return { tail: null, note: TAIL_WITHHELD_PACKET };
|
|
210
|
-
const raw = asText(text);
|
|
211
|
-
if (raw.length === 0) return { tail: '' };
|
|
212
|
-
if (Buffer.byteLength(raw) > STDERR_MAX_BYTES) return { tail: null, note: TAIL_WITHHELD_LARGE };
|
|
213
|
-
const packetLines = [
|
|
214
|
-
...new Set(
|
|
215
|
-
packetText
|
|
216
|
-
.split('\n')
|
|
217
|
-
.map((l) => l.trim())
|
|
218
|
-
.filter((l) => l.length >= PACKET_LINE_MIN),
|
|
219
|
-
),
|
|
220
|
-
];
|
|
221
|
-
let inPem = false;
|
|
222
|
-
/** @type {string[]} */
|
|
223
|
-
const kept = [];
|
|
224
|
-
for (const line of raw.split('\n')) {
|
|
225
|
-
const t = line.trim();
|
|
226
|
-
if (inPem) {
|
|
227
|
-
if (PEM_END.test(t)) inPem = false;
|
|
228
|
-
continue;
|
|
229
|
-
}
|
|
230
|
-
if (PEM_BEGIN.test(t)) {
|
|
231
|
-
inPem = !PEM_END.test(t);
|
|
232
|
-
continue;
|
|
233
|
-
}
|
|
234
|
-
if (t.length >= PACKET_LINE_MIN && packetLines.some((p) => t.includes(p))) continue;
|
|
235
|
-
kept.push(line);
|
|
236
|
-
}
|
|
237
|
-
const safe = /** @type {string} */ (redact(kept.join('\n')));
|
|
238
|
-
const buf = Buffer.from(safe, 'utf8');
|
|
239
|
-
if (buf.length <= maxBytes) return { tail: safe };
|
|
240
|
-
const from = charStart(buf, buf.length - maxBytes);
|
|
241
|
-
const piece = buf.subarray(from).toString('utf8');
|
|
242
|
-
if (buf[from - 1] === 0x0a) return { tail: piece }; // the cut fell exactly on a line start
|
|
243
|
-
const nl = piece.indexOf('\n');
|
|
244
|
-
if (nl === -1) return { tail: '', note: TAIL_WITHHELD_LONG_LINE };
|
|
245
|
-
return { tail: piece.slice(nl + 1) };
|
|
246
|
-
}
|
|
247
|
-
|
|
248
165
|
/**
|
|
249
166
|
* What a timed-out session's stdout says without any of its text (B30): the last
|
|
250
167
|
* `STDOUT_EVENTS_MAX` event types (JSON lines with a string `type`, each cut to 40 characters and
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The packet-free, redacted tail of a session's diagnostic text (B30): a timed-out session's
|
|
3
|
+
* stderr (`spawn.mjs`) and, since B55, a review answer that failed the schema
|
|
4
|
+
* (`review/schema-fallback.mjs`). Lives in `util/` so both layers can use the one filter.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { redact } from './redact.mjs';
|
|
8
|
+
|
|
9
|
+
/** How much of a timed-out session's stderr is kept (B30). */
|
|
10
|
+
export const DIAGNOSTIC_TAIL_BYTES = 4096;
|
|
11
|
+
|
|
12
|
+
/** A timed-out session's stderr over this size keeps no text at all (bounds the redact cost). */
|
|
13
|
+
export const STDERR_MAX_BYTES = 1024 * 1024;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Packet lines shorter than this are too common to tell apart from ordinary output, so they are
|
|
17
|
+
* NOT filtered. Accepted risk (B30): a packet line under 16 characters (a brace, `return x;`) can
|
|
18
|
+
* appear in a stderr tail; such a line carries no meaningful packet content on its own.
|
|
19
|
+
*/
|
|
20
|
+
const PACKET_LINE_MIN = 16;
|
|
21
|
+
|
|
22
|
+
/** The tail note when the packet cannot be read: no stderr text is kept at all (fail closed). */
|
|
23
|
+
export const TAIL_WITHHELD_PACKET = 'withheld: packet unreadable';
|
|
24
|
+
/** The tail note when stderr is over `STDERR_MAX_BYTES`. */
|
|
25
|
+
export const TAIL_WITHHELD_LARGE = 'stderr too large, withheld';
|
|
26
|
+
/** The tail note when the last 4 KB of stderr is one partial line. */
|
|
27
|
+
export const TAIL_WITHHELD_LONG_LINE = 'one long line, withheld';
|
|
28
|
+
|
|
29
|
+
/** @param {Buffer} buf @param {number} at @returns {number} the first character start at or after `at`. */
|
|
30
|
+
function charStart(buf, at) {
|
|
31
|
+
let i = at;
|
|
32
|
+
while (i < buf.length && (buf[i] & 0xc0) === 0x80) i += 1;
|
|
33
|
+
return i;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** @param {unknown} text @returns {string} */
|
|
37
|
+
export function asText(text) {
|
|
38
|
+
return Buffer.isBuffer(text) ? text.toString('utf8') : typeof text === 'string' ? text : '';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const PEM_BEGIN = /-----BEGIN [A-Z0-9 ]*-----/;
|
|
42
|
+
const PEM_END = /-----END [A-Z0-9 ]*-----/;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The stderr tail of a timed-out session (B30), never packet content:
|
|
46
|
+
* 1. no packet text (unreadable) ⇒ no text: `{tail: null, note: 'withheld: packet unreadable'}`;
|
|
47
|
+
* stderr over `STDERR_MAX_BYTES` ⇒ `{tail: null, note: 'stderr too large, withheld'}`;
|
|
48
|
+
* 2. on the FULL raw text: every PEM block (`-----BEGIN` … `-----END` lines, an unterminated one
|
|
49
|
+
* to the end) and every line repeating a packet line (≥ 16 chars) is dropped;
|
|
50
|
+
* 3. the FULL remaining text goes through B0 `redact`;
|
|
51
|
+
* 4. over `maxBytes`, the last `maxBytes` bytes are kept, starting on a character, with the first
|
|
52
|
+
* (partial) line dropped; when that piece has no line break the tail is withheld.
|
|
53
|
+
* @param {string | Buffer | null | undefined} text @param {string | null} packetText @param {number} [maxBytes]
|
|
54
|
+
* @returns {{tail: string | null, note?: string}}
|
|
55
|
+
*/
|
|
56
|
+
export function stderrTail(text, packetText, maxBytes = DIAGNOSTIC_TAIL_BYTES) {
|
|
57
|
+
if (typeof packetText !== 'string') return { tail: null, note: TAIL_WITHHELD_PACKET };
|
|
58
|
+
const raw = asText(text);
|
|
59
|
+
if (raw.length === 0) return { tail: '' };
|
|
60
|
+
if (Buffer.byteLength(raw) > STDERR_MAX_BYTES) return { tail: null, note: TAIL_WITHHELD_LARGE };
|
|
61
|
+
const packetLines = [
|
|
62
|
+
...new Set(
|
|
63
|
+
packetText
|
|
64
|
+
.split('\n')
|
|
65
|
+
.map((l) => l.trim())
|
|
66
|
+
.filter((l) => l.length >= PACKET_LINE_MIN),
|
|
67
|
+
),
|
|
68
|
+
];
|
|
69
|
+
let inPem = false;
|
|
70
|
+
/** @type {string[]} */
|
|
71
|
+
const kept = [];
|
|
72
|
+
for (const line of raw.split('\n')) {
|
|
73
|
+
const t = line.trim();
|
|
74
|
+
if (inPem) {
|
|
75
|
+
if (PEM_END.test(t)) inPem = false;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (PEM_BEGIN.test(t)) {
|
|
79
|
+
inPem = !PEM_END.test(t);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (t.length >= PACKET_LINE_MIN && packetLines.some((p) => t.includes(p))) continue;
|
|
83
|
+
kept.push(line);
|
|
84
|
+
}
|
|
85
|
+
const safe = /** @type {string} */ (redact(kept.join('\n')));
|
|
86
|
+
const buf = Buffer.from(safe, 'utf8');
|
|
87
|
+
if (buf.length <= maxBytes) return { tail: safe };
|
|
88
|
+
const from = charStart(buf, buf.length - maxBytes);
|
|
89
|
+
const piece = buf.subarray(from).toString('utf8');
|
|
90
|
+
if (buf[from - 1] === 0x0a) return { tail: piece }; // the cut fell exactly on a line start
|
|
91
|
+
const nl = piece.indexOf('\n');
|
|
92
|
+
if (nl === -1) return { tail: '', note: TAIL_WITHHELD_LONG_LINE };
|
|
93
|
+
return { tail: piece.slice(nl + 1) };
|
|
94
|
+
}
|
|
95
|
+
|