@codedology/code-forge 0.7.1 → 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.
@@ -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
+
@@ -15,7 +15,8 @@
15
15
  * last round reviewed and the current content), with the open findings by id;
16
16
  * - `next: patch` (the L3 rung at the cap) ⇒ the `patch_check` round; a `retry` re-runs its round;
17
17
  * - `stopped` (`review_cap`, `l3_patch_exhausted`, `split_required`) ⇒ no session: the result
18
- * repeats the stop — only the human moves on (§4.11 rule 5).
18
+ * repeats the stop — only the human moves on (§4.11 rule 5). A `split_required` result (round 1
19
+ * or a recheck stop) carries `tokens_in` and `budget` (B54).
19
20
  * The round's outcome: `approved: true` ONLY when the loop is `complete` AND its signed
20
21
  * `review.approved` row (keyed `run, block, file, content_hash` = the ticket's hash) was written;
21
22
  * else `findings` = the open fix list, `next` = what the orchestrator does (`fix` at `level`,
@@ -29,6 +30,10 @@
29
30
  * block's review budget (§4.10, B19): the forecast over the block's changed owned files (tier from
30
31
  * the path floors, before S1), one `review.budget` row per block; a failure there never fails the
31
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.
32
37
  * The review NEVER approves an empty or failed session: that is `unavailable` (§4.2 stub guard).
33
38
  * A throw inside the round is `unavailable` too, with the state saved as `next: retry` (see
34
39
  * `fixLoopRound`): the next ticket re-runs that round, never a fresh round 1.
@@ -44,6 +49,8 @@ import { reviewFile, rulesRisk } from '../review/engine.mjs';
44
49
  import { computeFileSet, ownsFile } from '../gates/scope.mjs';
45
50
  import { extraRoundsFor, newFileState, reopenForExtraRound, runRound } from '../review/fixloop.mjs';
46
51
  import { readRun } from '../state/run.mjs';
52
+ import { ledgerSchemaHistory } from '../review/schema-fallback.mjs';
53
+ import { verifyRow } from '../state/signer.mjs';
47
54
  import { redact } from '../util/redact.mjs';
48
55
  import { keyedLock } from '../util/locks.mjs';
49
56
  import { blockAnchors, blockRungUsed, loadState, saveState } from './review-state.mjs';
@@ -83,6 +90,10 @@ import { assertTicketId } from './ticket.mjs';
83
90
  * @property {string | null} [trigger] - `review_stall`, `review_rounds`, `review_cap`, or null.
84
91
  * @property {string} [stopped] - the stop reason (`review_cap`, `l3_patch_exhausted`, …).
85
92
  * @property {number} [late] - late findings recorded so far for the file.
93
+ * @property {number} [tokens_in] - `split_required` (or `stopped: split_required`): the estimated
94
+ * tokens of the diff-only packet that was over the budget (B54).
95
+ * @property {number} [budget] - with `tokens_in`: the packet budget it exceeded (B54).
96
+ * @property {string} [section] - a Markdown file's section that alone is over the budget (B54).
86
97
  */
87
98
 
88
99
  /**
@@ -302,7 +313,7 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, blockKindOf, key,
302
313
  reopenForExtraRound(state, extraRounds, ctx.cfg);
303
314
 
304
315
  if (state.status === 'stopped') {
305
- return { outcome: /** @type {ReviewOutcome} */ ({ ...loopFields(state, null), status: 'stopped', stopped: state.next?.reason ?? 'stopped', approved: false, engine: 'adaptive', sessions: [], findings: state.open }) };
316
+ return { outcome: /** @type {ReviewOutcome} */ ({ ...loopFields(state, null), status: 'stopped', stopped: state.next?.reason ?? 'stopped', ...stopSize(state), approved: false, engine: 'adaptive', sessions: [], findings: state.open }) };
306
317
  }
307
318
 
308
319
  /** @type {'full' | 'recheck' | 'patch_check'} */
@@ -350,6 +361,23 @@ async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKind
350
361
  /** @type {string | null} */
351
362
  let crashed = null;
352
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;
353
381
  await runRound(
354
382
  state,
355
383
  {
@@ -361,8 +389,9 @@ async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKind
361
389
  factsExcerpt,
362
390
  extraRounds,
363
391
  ...(jev ? { jev } : {}),
392
+ ...(schemaHistory ? { schemaHistory } : {}),
364
393
  review: async () => {
365
- 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 } : {}) });
366
395
  return engineOutcome;
367
396
  },
368
397
  spawn: async (opts) => {
@@ -404,7 +433,7 @@ async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKind
404
433
  if (!approvalWritten) return { ...head, status: 'unavailable', reason: 'approval-not-written', approved: false };
405
434
  return { ...head, status: 'reviewed', approved: true, findings: [] };
406
435
  }
407
- if (status === 'stopped') return { ...head, status: 'stopped', stopped: next?.reason ?? 'stopped', approved: false, findings: state.open };
436
+ if (status === 'stopped') return { ...head, status: 'stopped', stopped: next?.reason ?? 'stopped', ...stopSize(state), approved: false, findings: state.open };
408
437
  return { ...head, status: 'reviewed', approved: false, reason: 'findings-open', findings: state.open };
409
438
  }
410
439
 
@@ -457,6 +486,18 @@ function loopFields(state, kind) {
457
486
  return { round: state.round, ...(kind ? { kind } : {}), level: state.level, next: state.next, trigger: state.next?.trigger ?? null, late: state.late.length };
458
487
  }
459
488
 
489
+ /**
490
+ * B54: a `split_required` stop's packet size (`tokens_in`, `budget` — null when unknown — and a
491
+ * Markdown `section`), from the state's `next`; nothing for any other stop.
492
+ * @param {import('../review/fixloop.mjs').FileState} state @returns {{tokens_in?: number | null, budget?: number | null, section?: string}}
493
+ */
494
+ function stopSize(state) {
495
+ const next = state.next;
496
+ if (next?.reason !== 'split_required') return {};
497
+ const fin = (/** @type {unknown} */ v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
498
+ return { tokens_in: fin(next.tokens_in), budget: fin(next.budget), ...(typeof next.section === 'string' ? { section: next.section } : {}) };
499
+ }
500
+
460
501
  /**
461
502
  * S1 through `askJev` with the worker's key bound (the key stays in this process).
462
503
  * @param {string} key @returns {import('../review/triage.mjs').JevAsk}
@@ -159,6 +159,21 @@ export function loopResult(outcome) {
159
159
  return out;
160
160
  }
161
161
 
162
+ /**
163
+ * B54: the packet size of a `split_required` outcome (round 1 `status: split_required`, or a
164
+ * recheck `stopped: split_required`) — and ONLY of one: `tokens_in` and `budget` always (null when
165
+ * not a finite number), plus the Markdown `section` that alone is over the budget when one is named. The result
166
+ * file and the ledger `review.result` row carry the same fields.
167
+ * @param {Record<string, any>} outcome @returns {Record<string, any>}
168
+ */
169
+ export function packetSize(outcome) {
170
+ if (outcome.status !== 'split_required' && outcome.stopped !== 'split_required') return {};
171
+ return { tokens_in: finiteOrNull(outcome.tokens_in), budget: finiteOrNull(outcome.budget), ...(typeof outcome.section === 'string' ? { section: outcome.section } : {}) };
172
+ }
173
+
174
+ /** @param {unknown} v @returns {number | null} `v` when a finite number, else null. */
175
+ export const finiteOrNull = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
176
+
162
177
  /**
163
178
  * B52: the verdict a result is recorded with (its signed file and its ledger row), always with
164
179
  * `approved` and `open`: `approved: true, open: []` only when the outcome said so AND it lists no
@@ -302,6 +317,7 @@ export async function createWorker(opts, deps = {}) {
302
317
  engine: outcome.engine,
303
318
  ...(typeof outcome.reason === 'string' ? { reason: outcome.reason } : {}),
304
319
  ...(Array.isArray(outcome.findings) ? { findings: outcome.findings } : {}),
320
+ ...packetSize(outcome),
305
321
  ...loopResult(outcome),
306
322
  sessions: outcome.sessions,
307
323
  };
@@ -387,6 +403,8 @@ export async function createWorker(opts, deps = {}) {
387
403
  ...(typeof result.reason === 'string' ? { reason: result.reason } : {}),
388
404
  ...(typeof result.trigger === 'string' ? { trigger: result.trigger } : {}),
389
405
  ...(typeof result.stopped === 'string' ? { stopped: result.stopped } : {}),
406
+ // B54: a split_required result says how far over the budget it was (the result file's fields)
407
+ ...packetSize(result),
390
408
  });
391
409
  } catch {
392
410
  // the signed result file stands; the gate reads it (§4.6)