@codedology/code-forge 0.7.2 → 0.7.3

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
@@ -6,6 +6,15 @@ waits on a second real consumer (plan §10.4, Q17).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.7.3] — 2026-10-08
10
+
11
+ ### Added
12
+
13
+ - Review: a packet whose answer fails the schema twice is retried once on `review.second_levels.<level>` when configured
14
+ (signed `review.schema_fallback` row; never in consensus mode; budget and closed-book rules apply), and every schema
15
+ miss logs a structural diagnosis of the answer (`review.schema_invalid`: keys, types, schema errors or the parse error
16
+ offset, never the text); the autopilot binnacle shows each fallback as an incident (issue #2).
17
+
9
18
  ## [0.7.2] — 2026-10-07
10
19
 
11
20
  ### Changed
package/README.md CHANGED
@@ -12,6 +12,14 @@ for cross-model, parallel, evidence-gated feature delivery — on Claude Code, C
12
12
 
13
13
  Next release:
14
14
 
15
+ - **A model that keeps failing the answer format gets a stand-in.** When a reviewer's answer
16
+ fails the schema twice on the same packet and you set `review.second_levels` for that level, the
17
+ worker retries that packet once with the second model, and the ledger records it. Each failed
18
+ answer's shape (its keys, types and schema errors, never its text) is logged, so you can see
19
+ what went wrong.
20
+
21
+ In 0.7.2:
22
+
15
23
  - **Long documents get reviewed.** The packet budget (`review.budgets.full_in`) is now 32,000
16
24
  tokens, and a Markdown file still too large is reviewed section by section instead of refused;
17
25
  a `split_required` result now says its size and the budget.
@@ -4,7 +4,7 @@
4
4
  "plans/code-forge-plan-v1.3.md §10.4",
5
5
  "plans/NIGHT-LOG.md"
6
6
  ],
7
- "block_count": 57,
7
+ "block_count": 58,
8
8
  "baseline_ids": ["B0", "B1", "B2", "B3", "B4", "B5", "B6", "B7", "B8"],
9
9
  "baseline_note": "Wave 0-1 blocks, landed before v1.3; depends_on may name them, but they have no entry under blocks.",
10
10
  "blocks": {
@@ -778,6 +778,22 @@
778
778
  "B53"
779
779
  ],
780
780
  "note": "root-added 2026-10-08; GitHub issue #2 note of 2026-10-02 (25k-token brief never reviewed); also amends src/review/{packet,engine,fixloop}.mjs, src/worker/{engine,loop}.mjs, src/cli/{review,review-file}.mjs, schema/code-forge.schema.json, docs/reference/config.md, docs/how-it-works.md, skill/references/review.md"
781
+ },
782
+ "B55": {
783
+ "title": "Schema-miss fallback (issue #2): a review answer that fails the schema twice on the same packet (same run, file, content, packet text, level and role) is retried once on review.second_levels.<level> (its ladder minus the failed model and, for closed-book roles, minus Codex unless allowed) behind a signed review.schema_fallback row written before the spawn; never in consensus mode; tokens of both sessions summed; every miss logs a structural diagnosis (review.schema_invalid: bytes, kind, keys and types, schema errors, never the text); the autopilot binnacle shows each fallback",
784
+ "wave": 12,
785
+ "level": "L2",
786
+ "status": "landed",
787
+ "owned": [
788
+ "src/review/schema-fallback.mjs",
789
+ "src/util/diagnostic-tail.mjs",
790
+ "test/review/schema-fallback.test.mjs",
791
+ "test/worker/schema-fallback-wiring.test.mjs"
792
+ ],
793
+ "depends_on": [
794
+ "B54"
795
+ ],
796
+ "note": "root-added 2026-10-08; GitHub issue #2 note of 2026-10-06; also amends src/review/{engine,fixloop,validate-review}.mjs, src/worker/engine.mjs, src/session/spawn.mjs (stderr tail moved to util), src/autopilot/binnacle.mjs, test fakes, docs/how-it-works.md, skill/references/review.md"
781
797
  }
782
798
  }
783
799
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codedology/code-forge",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
4
4
  "description": "Plan/harden/code/review orchestration CLI and Agent Skill for cross-model, parallel, evidence-gated feature delivery.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -19,7 +19,7 @@ Every reviewer, recheck, judge and S2 session runs with cwd = a fresh empty temp
19
19
 
20
20
  ## §4 The stub guard — what counts as a review (R11)
21
21
 
22
- A result is a review only if the process exited 0, the JSON validates against the finding schema, `reviewed_hunks` equals the packet's hunk headers exactly and in order, and the output has at least `review.min_tokens_out` tokens (a clean pass — `passed: true`, no findings, a non-blank summary — needs only 40, whatever the diff size). Anything else is ledger `review.unavailable` (`exit`, `schema`, `hunks_mismatch`, `too_short`, `timeout`, `budget` — the run reached `budget.usd`, `ledger.md` §3.1) and **is not approval**; the worker retries once on the level's fallback, then the file stays unreviewed and the block cannot close. A session that hangs is killed at `review.session_timeout_s` (default 300), process group and all, and is spawned once more on the same packet and level; only a second timeout is `unavailable: timeout`. Each timed-out attempt leaves a `review.session_timeout` ledger note with the last 4 KB of its stderr (redacted, never the packet) and only the byte count and last event types of its stdout.
22
+ A result is a review only if the process exited 0, the JSON validates against the finding schema, `reviewed_hunks` equals the packet's hunk headers exactly and in order, and the output has at least `review.min_tokens_out` tokens (a clean pass — `passed: true`, no findings, a non-blank summary — needs only 40, whatever the diff size). Anything else is ledger `review.unavailable` (`exit`, `schema`, `hunks_mismatch`, `too_short`, `timeout`, `budget` — the run reached `budget.usd`, `ledger.md` §3.1) and **is not approval**; `review-file` on the same content re-runs the same packet once, then the file stays unreviewed and the block cannot close. Every `schema` miss leaves a signed `review.schema_invalid` row with a structural diagnosis of the answer — its size and kind (`json`, `not-json`, `empty`), its top-level keys and their types, and the schema errors (path, keyword, message), or the parse error and its byte offset — never the answer's text. At a packet's **second** `schema` miss (same run, file, content hash, packet text, level and role — the first try plus that re-run), the worker tries the packet ONCE on `review.second_levels.<level>` when the owner configured one, and writes a signed `review.schema_fallback {lens, role, level, packet_hash, from_provider, from_model, to, to_provider, to_model, attempts, refused?}` row first (no row written ⇒ no try; any earlier row for the packet at that level ⇒ no try). Its first model that is not the failed one — and, for a closed-book role, not Codex unless `review.allow_open_book_codex` — runs (`refused: same-model` or `refused: closed-book` when there is none). The session's tokens are both sessions'. Its answer goes through the same stub guard; a valid one is a normal answer, a failed one leaves the file unavailable. Never in consensus mode; `budget.usd` refuses it like any session. Without a second level only the diagnosis row is new. A session that hangs is killed at `review.session_timeout_s` (default 300), process group and all, and is spawned once more on the same packet and level; only a second timeout is `unavailable: timeout`. Each timed-out attempt leaves a `review.session_timeout` ledger note with the last 4 KB of its stderr (redacted, never the packet) and only the byte count and last event types of its stdout.
23
23
 
24
24
  ## §5 The convergence rule — bounded fix rounds (R15)
25
25
 
@@ -10,7 +10,7 @@
10
10
  # copied install with no package next to it); `test/skill/shim.test.mjs` asserts the pin
11
11
  # equals package.json's version.
12
12
  # The shim holds no logic of its own: every verb, flag and message is the CLI's.
13
- PINNED_VERSION="0.7.2"
13
+ PINNED_VERSION="0.7.3"
14
14
  set -eu
15
15
 
16
16
  self="$0"
@@ -39,7 +39,7 @@ export const LOG_FILE = 'autopilot-log.md';
39
39
  export const NONE_YET = 'none yet';
40
40
 
41
41
  /** The ledger events (besides every `autopilot.*`) that belong to the log and the timeline. */
42
- const EXTRA_EVENTS = Object.freeze(['block.close', 'review.session_timeout', 'worker.down', 'worker.replaced', 'ledger.tamper', 'budget.refused', 'gate.red', 'gate.done']);
42
+ const EXTRA_EVENTS = Object.freeze(['block.close', 'review.session_timeout', 'review.schema_fallback', 'worker.down', 'worker.replaced', 'ledger.tamper', 'budget.refused', 'gate.red', 'gate.done']);
43
43
 
44
44
  /** @typedef {Record<string, any>} Row */
45
45
  /** @typedef {{home?: string | null, cwd?: string | null}} ScrubCtx */
@@ -146,6 +146,8 @@ function describe(row) {
146
146
  return { event: 'Block closed', detail: `status ${row.status ?? 'unknown'}${short(first(row.commit, row.sha, row.commit_sha)) ? ` · commit ${short(first(row.commit, row.sha, row.commit_sha))}` : ''}`, link: first(row.pr_url, row.pr) };
147
147
  case 'review.session_timeout':
148
148
  return { event: 'Review session timed out', detail: `${row.lens ?? row.role ?? 'session'} · attempt ${u(row.attempt)} · ${row.retried === true ? 'retried' : 'gave up'}`, link: null };
149
+ case 'review.schema_fallback':
150
+ return { event: 'Review answer off-schema twice', detail: `${row.lens ?? row.role ?? 'session'} · ${u(row.from_model, 'unknown model')} → ${u(row.to_model, 'unknown model')} · ${typeof row.refused === 'string' ? `refused (${u(row.refused)})` : 'retried once'}`, link: null };
149
151
  case 'worker.down':
150
152
  return { event: 'Worker down', detail: 'the pinned worker is not running', link: null };
151
153
  case 'worker.replaced':
@@ -181,6 +183,31 @@ export function buildFullLog({ runId, rows, scrubCtx }) {
181
183
  /** @param {Row} row @returns {string} the subject line of an event, e.g. ` · b1 · src/a.mjs`. */
182
184
  const where = (row) => `${str(row.block) ? ` · ${u(row.block)}` : ''}${str(row.file) ? ` · ${u(row.file)}` : ''}`;
183
185
 
186
+ /**
187
+ * B55: what the owner does about a refused second-level try, by refusal. `schema-fallback.mjs`
188
+ * writes `closed-book` (every model left is Codex for a closed-book role) and `same-model`
189
+ * (nothing but the failed model is configured). No writer puts `budget` in this row today — the
190
+ * budget gate refuses inside the spawn and writes its own `budget.refused` row — so `budget` is
191
+ * shown only when a row carries it; any other string gets the generic fix.
192
+ */
193
+ const SCHEMA_FALLBACK_FIX = Object.freeze({
194
+ 'closed-book': 'Set a second level that can run closed-book (not Codex), or review the file by hand.',
195
+ 'same-model': 'Set review.second_levels to a different model than the one that failed, or review the file by hand.',
196
+ budget: 'Raise the budget (only you can) or review the file by hand.',
197
+ });
198
+
199
+ /**
200
+ * The incident of a `review.schema_fallback` row (B55): retried once, or refused and why.
201
+ * @param {Row} r @param {string | null} time
202
+ * @returns {{time: string | null, what: string, effect: string, fix: string}}
203
+ */
204
+ function schemaFallbackIncident(r, time) {
205
+ const what = `A ${r.lens ?? r.role ?? 'review'} answer failed the schema twice on the same packet (${u(r.from_model, 'unknown model')})`;
206
+ if (typeof r.refused !== 'string') return { time, what, effect: `It was retried once on the second level (${u(r.to_model, 'unknown model')}).`, fix: "Check the file's next review result." };
207
+ const fix = Object.hasOwn(SCHEMA_FALLBACK_FIX, r.refused) ? SCHEMA_FALLBACK_FIX[/** @type {keyof typeof SCHEMA_FALLBACK_FIX} */ (r.refused)] : 'Review the file by hand.';
208
+ return { time, what, effect: `The second-level try was refused (${u(r.refused)}); the review stays unavailable.`, fix };
209
+ }
210
+
184
211
  /**
185
212
  * @param {Row[]} blockRows rows of one block @param {Row | undefined} entry its run-record entry
186
213
  * @param {string} id
@@ -285,6 +312,7 @@ export function buildBinnacle({ runId, rows, record, now, scrubCtx = scrubCtxOf(
285
312
  for (const { row, time } of mine) {
286
313
  const r = row;
287
314
  if (r.event === 'review.session_timeout') incidents.push({ time, what: `A ${r.lens ?? r.role ?? 'review'} session timed out (attempt ${u(r.attempt)})`, effect: r.retried === true ? 'It was retried.' : 'No retry left; the review was marked unavailable.', fix: r.retried === true ? 'None needed unless it repeats.' : 'Run the review again.' });
315
+ else if (r.event === 'review.schema_fallback') incidents.push(schemaFallbackIncident(r, time));
288
316
  else if (r.event === 'worker.down' || r.event === 'worker.replaced') incidents.push({ time, what: r.event === 'worker.down' ? 'The pinned worker was down' : 'The pinned worker was replaced', effect: 'Reviews wait until the worker is back.', fix: 'Restart the worker for this run.' });
289
317
  else if (r.event === 'ledger.tamper') incidents.push({ time, what: `A ledger check failed for block ${r.block ?? 'unknown'}`, effect: 'The block was stopped.', fix: 'Look at the block before reopening it.' });
290
318
  else if (r.event === 'autopilot.issue_failed') incidents.push({ time, what: `The waiver issue for ${u(r.finding, 'unknown finding')} in ${u(r.file, 'unknown file')} could not be opened`, effect: 'The waiver stands without an issue.', fix: 'Open the tracking issue by hand.' });
@@ -20,6 +20,12 @@
20
20
  * 6. a session that ends in `timeout` is spawned ONCE more on the same packet and level
21
21
  * (`session-retry.mjs`, B30); a second timeout is `unavailable: timeout`. The summary's
22
22
  * `attempts` says how many ran;
23
+ * 6b. a session whose answer fails the schema logs it (`review.schema_invalid`, the raw answer
24
+ * redacted); at the packet's second such miss it is tried ONCE on `review.second_levels.<level>`
25
+ * when the owner configured one (`schema-fallback.mjs`, B55; never in consensus mode).
26
+ * The summary then carries `schema_fallback: {level, provider, model}` (`level`:
27
+ * `review.second_levels.<level>`), the second level's own provider/model, and `tokens_in` /
28
+ * `tokens_out` summed over both sessions;
23
29
  * 7. a diff alone over the packet budget is `split_required` (with `tokens_in` and `budget`) —
24
30
  * except a Markdown file (B54), which is reviewed SECTION BY SECTION (`sections.mjs`): every
25
31
  * section packet runs the whole plan above (its own sessions, judge and stub guard, its own
@@ -44,6 +50,7 @@ import { assembleJudgePacket, assemblePacket, attachFiles, budgetFor, diffOnlyTo
44
50
  import { isMarkdownFile, packSections, sectionFindings, sectionGroups } from './sections.mjs';
45
51
  import { FINDING_SCHEMA, minTokensOut, validateReview } from './validate-review.mjs';
46
52
  import { spawnWithTimeoutRetry } from './session-retry.mjs';
53
+ import { afterSchemaMiss, logSecondLevelMiss } from './schema-fallback.mjs';
47
54
  import { assertRowPath } from '../worker/ticket.mjs';
48
55
 
49
56
  /**
@@ -161,7 +168,10 @@ export function rulesRisk({ file, plusCount, cfg }) {
161
168
  * session (`spawnSession` opts minus the worker-pinned ones); a per-call `cfg` selects another
162
169
  * provider's level for the same slot (consensus).
163
170
  * @property {(row: Record<string, any>) => Promise<unknown>} [writeRow] - ledger rows
164
- * (`review.plan`, `review.unavailable`); a failed write never fails the review.
171
+ * (`review.plan`, `review.unavailable`, `review.schema_invalid`, `review.schema_fallback`); a
172
+ * failed write never fails the review (a failed `review.schema_fallback` write means no try).
173
+ * @property {import('./schema-fallback.mjs').SchemaHistory} [schemaHistory] - B55: the packet's
174
+ * signed schema-miss history (the worker's); absent ⇒ no second-level try.
165
175
  */
166
176
 
167
177
  /** @param {ReviewDeps} deps @param {Record<string, any>} row */
@@ -203,11 +213,13 @@ export async function reviewFile(input, deps) {
203
213
  if (diff.diffText.trim().length === 0) return { status: 'no_change', approved: false, ...head, sessions: [] };
204
214
  mkdirSync(workDir, { recursive: true, mode: 0o700 });
205
215
  const min = minTokensOut(cfg);
216
+ // B55: never in consensus mode — its providers (two reviewers, the judge) are the check itself
217
+ const schemaFallback = plan.mode !== 'consensus';
206
218
 
207
219
  /** @param {SessionSpec} spec @param {{text: string, hunkHeaders: string[], tokensIn: number, contextMode: string | null}} packet */
208
220
  const run = async (spec, packet) => {
209
221
  /** @type {Record<string, any>} */
210
- let summary = await runOne(spec, packet.text, packet, { deps, workDir, min, cfg });
222
+ let summary = await runOne(spec, packet.text, packet, { deps, workDir, min, cfg, schemaFallback });
211
223
  const needs = summary.review?.needs_file;
212
224
  if (spec.role === 'reviewer' && summary.status === 'ok' && Array.isArray(needs) && needs.length > 0) {
213
225
  const paths = safeNeeds(needs);
@@ -218,7 +230,7 @@ export async function reviewFile(input, deps) {
218
230
  extra = null;
219
231
  }
220
232
  if (extra === null) return { ...summary, status: 'unavailable', reason: 'needs_file-refused', review: null };
221
- summary = { ...(await runOne(spec, `${packet.text}${extra}`, packet, { deps, workDir, min, cfg })), needs_file_round: true };
233
+ summary = { ...(await runOne(spec, `${packet.text}${extra}`, packet, { deps, workDir, min, cfg, schemaFallback })), needs_file_round: true };
222
234
  }
223
235
  return summary;
224
236
  };
@@ -247,7 +259,7 @@ export async function reviewFile(input, deps) {
247
259
  if (judgeSpec) {
248
260
  const bySlot = Object.fromEntries(done.map((s) => [s.slot, s.review]));
249
261
  const packet = assembleJudgePacket({ diff: part, reports: { A: bySlot.A, B: bySlot.B }, cfg });
250
- final = await runOne(judgeSpec, packet.text, packet, { deps, workDir, min, cfg });
262
+ final = await runOne(judgeSpec, packet.text, packet, { deps, workDir, min, cfg, schemaFallback });
251
263
  sessions.push(publicSummary(final));
252
264
  if (final.status !== 'ok') {
253
265
  await note(deps, { event: 'review.unavailable', reason: final.reason, lens: 'judge', ...rowExtra });
@@ -320,22 +332,29 @@ export async function reviewFile(input, deps) {
320
332
  }
321
333
 
322
334
  /**
323
- * One session: packet file (0600, removed after), spawn, stub guard.
335
+ * One session: packet file (0600, removed after), spawn, stub guard — and, on a `schema` miss, the
336
+ * B55 second-level try (`schema-fallback.mjs`) on the same packet file before it is removed.
324
337
  * @param {SessionSpec} spec @param {string} text
325
338
  * @param {{hunkHeaders: string[], tokensIn: number, contextMode: string | null}} packet
326
- * @param {{deps: ReviewDeps, workDir: string, min: number, cfg: Record<string, any>}} env
339
+ * @param {{deps: ReviewDeps, workDir: string, min: number, cfg: Record<string, any>, schemaFallback?: boolean}} env -
340
+ * `schemaFallback: false` (consensus mode: its providers ARE the check) never tries.
327
341
  */
328
- async function runOne(spec, text, packet, { deps, workDir, min }) {
342
+ async function runOne(spec, text, packet, { deps, workDir, min, cfg, schemaFallback = true }) {
329
343
  const promptPath = path.join(workDir, `${spec.lens}-${randomBytes(6).toString('hex')}.md`);
330
344
  const base = { lens: spec.lens, role: spec.role, level: spec.level, slot: spec.slot, context_mode: packet.contextMode, ctx_tokens_in: packet.tokensIn };
331
345
  writeFileSync(promptPath, text, { mode: 0o600 });
332
346
  /** @type {Record<string, any> | null} */
333
347
  let res = null;
334
348
  let attempts = 0;
335
- try {
349
+ /** @type {import('./validate-review.mjs').Verdict} */
350
+ let verdict = { ok: false, reason: 'exit', detail: 'no session result' };
351
+ /** @type {{level: string, provider: string, model: string} | null} */
352
+ let fellBackTo = null;
353
+ /** @param {Record<string, any> | undefined} sessionCfg @param {number} done - attempts before this call. */
354
+ const attempt = (sessionCfg, done) =>
336
355
  // a `timeout` is spawned once more on the same packet and level (B30); a spawn that throws
337
356
  // (bad config, refused argv) is an `exit` failure, never approval
338
- ({ res, attempts } = await spawnWithTimeoutRetry(
357
+ spawnWithTimeoutRetry(
339
358
  deps.spawn,
340
359
  {
341
360
  level: spec.level,
@@ -343,35 +362,88 @@ async function runOne(spec, text, packet, { deps, workDir, min }) {
343
362
  promptPath,
344
363
  schema: FINDING_SCHEMA,
345
364
  rowExtra: { lens: spec.lens, context_mode: packet.contextMode, ctx_tokens_in: packet.tokensIn },
346
- ...(spec.cfg ? { cfg: spec.cfg } : {}),
365
+ ...(sessionCfg ? { cfg: sessionCfg } : {}),
347
366
  },
348
367
  (row) => note(deps, row),
349
368
  (n) => {
350
- attempts = n;
369
+ attempts = done + n;
351
370
  },
352
- ));
353
- } catch {
354
- // defensive only: the helper never throws. If it ever did, it is still an `exit` failure,
355
- // never approval, with the attempts it had reported so far (at least 1).
356
- res = null;
357
- attempts = Math.max(attempts, 1);
371
+ );
372
+ /** @type {number | null} the session's tokens: the first spawn's, plus a second-level try's */
373
+ let tokensIn = null;
374
+ /** @type {number | null} */
375
+ let tokensOut = null;
376
+ // the packet file is removed on every path, and only after the second-level try (which reads it)
377
+ try {
378
+ try {
379
+ ({ res, attempts } = await attempt(spec.cfg, 0));
380
+ } catch {
381
+ // defensive only: the helper never throws. If it ever did, it is still an `exit` failure,
382
+ // never approval, with the attempts it had reported so far (at least 1).
383
+ res = null;
384
+ attempts = Math.max(attempts, 1);
385
+ }
386
+ tokensIn = res?.usage?.tokens_in ?? null;
387
+ tokensOut = res?.usage?.tokens_out ?? null;
388
+ verdict = validateReview(res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
389
+ if (!verdict.ok && verdict.reason === 'schema') {
390
+ try {
391
+ const session = { lens: spec.lens, role: spec.role, level: spec.level };
392
+ const done = attempts;
393
+ // no ledger writer ⇒ nothing logged, nothing tried; every row goes through the same writer
394
+ // (each helper handles its own failed write)
395
+ const write = deps.writeRow ? (/** @type {Record<string, any>} */ row) => /** @type {NonNullable<ReviewDeps['writeRow']>} */ (deps.writeRow)(row) : undefined;
396
+ const second = await afterSchemaMiss({
397
+ res,
398
+ session,
399
+ packetText: text,
400
+ cfg: spec.cfg ?? cfg,
401
+ allowed: schemaFallback && !spec.cfg,
402
+ history: deps.schemaHistory,
403
+ writeRow: write,
404
+ spawnAt: (c) => attempt(c, done),
405
+ });
406
+ if (second) {
407
+ const secondVerdict = validateReview(second.res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
408
+ res = second.res;
409
+ attempts = done + second.attempts;
410
+ fellBackTo = second.to;
411
+ verdict = secondVerdict;
412
+ tokensIn = addTokens(tokensIn, second.res?.usage?.tokens_in);
413
+ tokensOut = addTokens(tokensOut, second.res?.usage?.tokens_out);
414
+ if (!verdict.ok && verdict.reason === 'schema') await logSecondLevelMiss({ res, session, packetText: text, writeRow: write });
415
+ }
416
+ } catch {
417
+ // defensive only: the helpers never throw. If they ever did, the first session's result
418
+ // and its `schema` verdict stand (never turned into `exit`, never approval)
419
+ }
420
+ }
358
421
  } finally {
359
422
  rmSync(promptPath, { force: true });
360
423
  }
361
- const verdict = validateReview(res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
362
424
  const meta = {
363
425
  ...base,
364
426
  provider: res?.provider ?? null,
365
427
  model: res?.model ?? null,
366
428
  fallback_step: res?.fallback_step ?? 0,
367
429
  attempts,
368
- tokens_in: res?.usage?.tokens_in ?? null,
369
- tokens_out: res?.usage?.tokens_out ?? null,
430
+ ...(fellBackTo ? { schema_fallback: fellBackTo } : {}),
431
+ tokens_in: tokensIn,
432
+ tokens_out: tokensOut,
370
433
  };
371
434
  if (!verdict.ok) return { ...meta, status: 'unavailable', reason: verdict.reason, review: null };
372
435
  return { ...meta, status: 'ok', reason: null, review: verdict.review };
373
436
  }
374
437
 
438
+ /**
439
+ * Two token counts added; a missing one (not a number) adds nothing, both missing ⇒ null.
440
+ * @param {unknown} a @param {unknown} b @returns {number | null}
441
+ */
442
+ function addTokens(a, b) {
443
+ if (typeof a !== 'number') return typeof b === 'number' ? b : null;
444
+ return typeof b === 'number' ? a + b : a;
445
+ }
446
+
375
447
  /** The per-session summary the worker signs: the answer's verdict and findings, never the packet. */
376
448
  function publicSummary(/** @type {Record<string, any>} */ s) {
377
449
  const { review, ...rest } = s;
@@ -59,6 +59,10 @@
59
59
  * findings get ids `S<section>.<id>`; any section session unavailable ⇒ `retry` of the whole
60
60
  * round. A section still over the budget alone ⇒ the terminal stop above (`split_required`).
61
61
  *
62
+ * Schema misses (B55): a recheck (or patch_check) session whose answer fails the schema at its
63
+ * packet's second miss is tried once on `review.second_levels.L2` when configured
64
+ * (`schema-fallback.mjs`); only when that fails too is the round `unavailable` as below.
65
+ *
62
66
  * A session that fails the stub guard is `unavailable`, and a failed write of a gate-relevant row
63
67
  * (`review.late_finding`, `review.round`, `review.cap`) is `ledger_write_failed`: either way the
64
68
  * round is not counted, nothing closes, and `next.action: 'retry'` with `pending_kind` set —
@@ -79,6 +83,7 @@ import { isMarkdownFile, packSections, sectionFindings, sectionGroups, sectionOf
79
83
  import { cameFromJudge, resolvedBand, triageFindings } from './triage.mjs';
80
84
  import { FINDING_SCHEMA, minTokensOut, validateReview } from './validate-review.mjs';
81
85
  import { spawnWithTimeoutRetry } from './session-retry.mjs';
86
+ import { afterSchemaMiss, logSecondLevelMiss } from './schema-fallback.mjs';
82
87
 
83
88
  /** @typedef {import('./triage.mjs').Finding} Finding */
84
89
  /** @typedef {import('./context.mjs').Hunk} Hunk */
@@ -126,6 +131,7 @@ import { spawnWithTimeoutRetry } from './session-retry.mjs';
126
131
  * @property {string | null} [base] - the block base (only for `recheck_scope: file`).
127
132
  * @property {string} [rulesDigest] @property {string} [factsExcerpt]
128
133
  * @property {number} [extraRounds] - B47: rounds past `review.max_rounds_per_file` granted by autopilot (`extraRoundsFor`).
134
+ * @property {import('./schema-fallback.mjs').SchemaHistory} [schemaHistory] - B55: the packet's signed schema-miss history; absent ⇒ no second-level try.
129
135
  */
130
136
 
131
137
  /** @param {LoopDeps} deps @param {Record<string, any>} row */
@@ -353,7 +359,8 @@ export function insideHunks(f, hunks) {
353
359
  }
354
360
 
355
361
  /**
356
- * One recheck session through the stub guard.
362
+ * One recheck session through the stub guard — and, on a `schema` miss, the B55 second-level try
363
+ * on the same packet (`schema-fallback.mjs`; rechecks and the patch_check alike).
357
364
  * @param {{text: string, hunkHeaders: string[], tokensIn: number, contextMode: string}} packet
358
365
  * @param {LoopDeps} deps
359
366
  */
@@ -361,26 +368,62 @@ async function recheckSession(packet, deps) {
361
368
  mkdirSync(deps.workDir, { recursive: true, mode: 0o700 });
362
369
  const promptPath = path.join(deps.workDir, `recheck-${randomBytes(6).toString('hex')}.md`);
363
370
  writeFileSync(promptPath, packet.text, { mode: 0o600 });
371
+ const min = minTokensOut(deps.cfg);
372
+ const session = { lens: 'recheck', role: 'reviewer', level: 'L2' };
373
+ /** @param {Record<string, any> | undefined} cfg */
374
+ const attempt = async (cfg) =>
375
+ deps.spawn
376
+ ? // a `timeout` is spawned once more on the same packet and level (B30)
377
+ spawnWithTimeoutRetry(
378
+ deps.spawn,
379
+ { level: 'L2', role: 'reviewer', promptPath, schema: FINDING_SCHEMA, rowExtra: { lens: 'recheck', context_mode: packet.contextMode, ctx_tokens_in: packet.tokensIn }, ...(cfg ? { cfg } : {}) },
380
+ (row) => note(deps, row),
381
+ )
382
+ : { res: null, attempts: 0 };
364
383
  /** @type {Record<string, any> | null} */
365
384
  let res = null;
385
+ /** @type {import('./validate-review.mjs').Verdict} */
386
+ let verdict = { ok: false, reason: 'exit', detail: 'no session result' };
387
+ /** @type {number | null} the round's tokens: the first session's, plus a second-level try's */
388
+ let tokensIn = null;
389
+ /** @type {number | null} */
390
+ let tokensOut = null;
391
+ // the packet file is removed on every path, and only after the second-level try (which reads it)
366
392
  try {
367
- // a `timeout` is spawned once more on the same packet and level (B30)
368
- res = deps.spawn
369
- ? (
370
- await spawnWithTimeoutRetry(
371
- deps.spawn,
372
- { level: 'L2', role: 'reviewer', promptPath, schema: FINDING_SCHEMA, rowExtra: { lens: 'recheck', context_mode: packet.contextMode, ctx_tokens_in: packet.tokensIn } },
373
- (row) => note(deps, row),
374
- )
375
- ).res
376
- : null;
377
- } catch {
378
- res = null; // the helper never throws; if it ever did, it is still an `exit` failure
393
+ try {
394
+ res = (await attempt(undefined)).res;
395
+ } catch {
396
+ res = null; // the helper never throws; if it ever did, it is still an `exit` failure
397
+ }
398
+ tokensIn = res?.usage?.tokens_in ?? null;
399
+ tokensOut = res?.usage?.tokens_out ?? null;
400
+ verdict = validateReview(res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
401
+ if (!verdict.ok && verdict.reason === 'schema') {
402
+ try {
403
+ // no ledger writer ⇒ nothing logged, nothing tried; every row goes through the same writer
404
+ // (each helper handles its own failed write)
405
+ const write = deps.writeRow ? (/** @type {Record<string, any>} */ row) => /** @type {NonNullable<LoopDeps['writeRow']>} */ (deps.writeRow)(row) : undefined;
406
+ const second = await afterSchemaMiss({ res, session, packetText: packet.text, cfg: deps.cfg ?? {}, allowed: true /* rechecks are never consensus; the budget gate is inside the spawn */, history: deps.schemaHistory, writeRow: write, spawnAt: attempt });
407
+ if (second) {
408
+ // compute everything first, then assign together, so a throw never leaves a mixed state
409
+ const secondVerdict = validateReview(second.res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
410
+ const sumIn = sum(tokensIn, second.res?.usage?.tokens_in);
411
+ const sumOut = sum(tokensOut, second.res?.usage?.tokens_out);
412
+ res = second.res;
413
+ verdict = secondVerdict;
414
+ tokensIn = sumIn;
415
+ tokensOut = sumOut;
416
+ if (!verdict.ok && verdict.reason === 'schema') await logSecondLevelMiss({ res, session, packetText: packet.text, writeRow: write });
417
+ }
418
+ } catch {
419
+ // defensive only: the helpers never throw. If they ever did, the first session's result
420
+ // and its `schema` verdict stand (never turned into `exit`, never approval)
421
+ }
422
+ }
379
423
  } finally {
380
424
  rmSync(promptPath, { force: true });
381
425
  }
382
- const verdict = validateReview(res, { hunkHeaders: packet.hunkHeaders, minTokensOut: minTokensOut(deps.cfg) });
383
- return { verdict, tokens_in: res?.usage?.tokens_in ?? null, tokens_out: res?.usage?.tokens_out ?? null };
426
+ return { verdict, tokens_in: tokensIn, tokens_out: tokensOut };
384
427
  }
385
428
 
386
429
  /**
@@ -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
+ }
@@ -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;
@@ -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
- /** How much of a timed-out session's stderr is kept (B30). */
156
- export const DIAGNOSTIC_TAIL_BYTES = 4096;
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
+
@@ -30,6 +30,10 @@
30
30
  * block's review budget (§4.10, B19): the forecast over the block's changed owned files (tier from
31
31
  * the path floors, before S1), one `review.budget` row per block; a failure there never fails the
32
32
  * review.
33
+ * B55: every round gets the packet's signed schema-miss history (`ledgerSchemaHistory`: rows of
34
+ * this run for this file and content hash whose MAC verifies with the run key), so a packet's
35
+ * second `schema` miss — this round's first try after the earlier ticket's — is tried once on
36
+ * `review.second_levels.<level>` when one is configured.
33
37
  * The review NEVER approves an empty or failed session: that is `unavailable` (§4.2 stub guard).
34
38
  * A throw inside the round is `unavailable` too, with the state saved as `next: retry` (see
35
39
  * `fixLoopRound`): the next ticket re-runs that round, never a fresh round 1.
@@ -45,6 +49,8 @@ import { reviewFile, rulesRisk } from '../review/engine.mjs';
45
49
  import { computeFileSet, ownsFile } from '../gates/scope.mjs';
46
50
  import { extraRoundsFor, newFileState, reopenForExtraRound, runRound } from '../review/fixloop.mjs';
47
51
  import { readRun } from '../state/run.mjs';
52
+ import { ledgerSchemaHistory } from '../review/schema-fallback.mjs';
53
+ import { verifyRow } from '../state/signer.mjs';
48
54
  import { redact } from '../util/redact.mjs';
49
55
  import { keyedLock } from '../util/locks.mjs';
50
56
  import { blockAnchors, blockRungUsed, loadState, saveState } from './review-state.mjs';
@@ -355,6 +361,23 @@ async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKind
355
361
  /** @type {string | null} */
356
362
  let crashed = null;
357
363
  try {
364
+ // B55: a packet's schema misses are counted from the run's signed rows for this run, file,
365
+ // content hash AND packet hash (`ledgerSchemaHistory` is asked per packet). Built inside the
366
+ // guarded block: a throw here is this round's `unavailable` with `next: retry`, like any other;
367
+ // a ledger read that throws later only disables the second-level try (with one warning line).
368
+ // Once per packet holds because this ticket holds the (block, file) file lock (`loop.mjs`) for
369
+ // the whole round — round 1, its retry, a recheck and the patch_check alike — and
370
+ // `schema-fallback.mjs` adds a per-packet lock around read-history → write-mark.
371
+ // The rows are matched on the values the worker's writeRow stamps on them (`loop.mjs`:
372
+ // `{...row, block, file, content_hash: ticket.content_hash}`): `ticket.file` and
373
+ // `ticket.content_hash` here are the same ticket's, for round 1 and rechecks alike (a recheck
374
+ // ticket's hash is the fixed content's). `where.key` is the run key: `loop.mjs` puts the run's
375
+ // `key` in `ctx.key` (`key, // signs the fix-loop state`), `reviewTicket` passes `key: ctx.key`
376
+ // to `fixLoopRound`, which builds `where = {…, key, rows}`. A row whose check throws is not
377
+ // counted (`ledgerSchemaHistory` catches per row).
378
+ const schemaHistory = ctx.readRows
379
+ ? ledgerSchemaHistory({ readRows: ctx.readRows, verify: (row) => verifyRow(row, where.key).ok, runId: ctx.runId, file: ticket.file, contentHash: ticket.content_hash })
380
+ : undefined;
358
381
  await runRound(
359
382
  state,
360
383
  {
@@ -366,8 +389,9 @@ async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKind
366
389
  factsExcerpt,
367
390
  extraRounds,
368
391
  ...(jev ? { jev } : {}),
392
+ ...(schemaHistory ? { schemaHistory } : {}),
369
393
  review: async () => {
370
- engineOutcome = await reviewFile({ repoRoot: ctx.repoRoot, file: ticket.file, base, cfg: ctx.cfg, kind: blockKindOf, workDir, factsExcerpt }, { spawn: ctx.spawn, writeRow });
394
+ engineOutcome = await reviewFile({ repoRoot: ctx.repoRoot, file: ticket.file, base, cfg: ctx.cfg, kind: blockKindOf, workDir, factsExcerpt }, { spawn: ctx.spawn, writeRow, ...(schemaHistory ? { schemaHistory } : {}) });
371
395
  return engineOutcome;
372
396
  },
373
397
  spawn: async (opts) => {