@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.
- package/CHANGELOG.md +23 -0
- package/README.md +14 -0
- package/docs/reference/blocks.json +33 -1
- package/docs/reference/config.md +1 -1
- package/package.json +1 -1
- package/schema/code-forge.schema.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/cli/review-file.mjs +9 -1
- package/src/cli/review.mjs +11 -3
- package/src/review/engine.mjs +192 -46
- package/src/review/fixloop.mjs +212 -45
- package/src/review/packet.mjs +32 -7
- package/src/review/schema-fallback.mjs +351 -0
- package/src/review/sections.mjs +282 -0
- 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 +45 -4
- package/src/worker/loop.mjs +18 -0
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
|
+
|
package/src/worker/engine.mjs
CHANGED
|
@@ -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}
|
package/src/worker/loop.mjs
CHANGED
|
@@ -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)
|