@codedology/code-forge 0.9.0 → 0.10.0

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.
Files changed (55) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +29 -8
  3. package/docs/reference/blocks.json +385 -1
  4. package/docs/reference/config.md +36 -0
  5. package/package.json +1 -1
  6. package/schema/code-forge.schema.json +89 -0
  7. package/skill/references/adapters/claude-code.md +1 -1
  8. package/skill/references/adapters/solo.md +2 -2
  9. package/skill/references/adapters/subprocess.md +12 -2
  10. package/skill/references/code.md +8 -3
  11. package/skill/references/ledger.md +2 -2
  12. package/skill/references/proof.md +77 -2
  13. package/skill/references/review.md +11 -4
  14. package/skill/references/security.md +4 -4
  15. package/skill/scripts/forge +1 -1
  16. package/skill/templates/coder-brief.md +2 -0
  17. package/src/autopilot/binnacle.mjs +9 -3
  18. package/src/cli/block.mjs +164 -24
  19. package/src/cli/jev.mjs +22 -3
  20. package/src/cli/keys.mjs +156 -21
  21. package/src/cli/proof.mjs +35 -7
  22. package/src/cli/spawn.mjs +44 -3
  23. package/src/config/validate.mjs +320 -5
  24. package/src/doctor/index.mjs +2 -1
  25. package/src/doctor/local.mjs +27 -6
  26. package/src/doctor/probes.mjs +38 -0
  27. package/src/engines/builders/codex.mjs +44 -4
  28. package/src/engines/sandbox-dirs.mjs +257 -0
  29. package/src/keys/store.mjs +128 -21
  30. package/src/proof/junit.mjs +530 -0
  31. package/src/proof/red-green.mjs +57 -9
  32. package/src/proof/red-parse.mjs +2 -0
  33. package/src/proof/runners.mjs +472 -0
  34. package/src/proof/xcresult.mjs +285 -0
  35. package/src/review/engine.mjs +82 -30
  36. package/src/review/fixloop.mjs +45 -13
  37. package/src/review/gate-check.mjs +3 -2
  38. package/src/review/lenses/A.md +1 -0
  39. package/src/review/lenses/B.md +1 -0
  40. package/src/review/lenses/full.md +1 -0
  41. package/src/review/lenses/quick.md +1 -0
  42. package/src/review/lenses/recheck.md +1 -0
  43. package/src/review/reopen.mjs +4 -6
  44. package/src/review/route.mjs +232 -0
  45. package/src/review/schema-fallback.mjs +77 -22
  46. package/src/review/session-retry.mjs +44 -9
  47. package/src/review/test-quality.mjs +201 -0
  48. package/src/review/verb-forms.mjs +39 -0
  49. package/src/session/spawn.mjs +39 -2
  50. package/src/state/block.mjs +13 -3
  51. package/src/state/config-snapshot.mjs +39 -0
  52. package/src/state/run.mjs +4 -0
  53. package/src/util/forbidden.mjs +2 -2
  54. package/src/worker/loop.mjs +79 -14
  55. package/src/worker/ticket.mjs +9 -1
@@ -8,8 +8,8 @@
8
8
  * Human-only like `block waive`: the verb is on `CODER_ONLY_FORBIDDEN` (`src/util/forbidden.mjs`)
9
9
  * and its row is signed with the run key, `by: human` (`block waive` has no other in-verb guard,
10
10
  * so neither has this). The verb (`reopenFile`) refuses, writing no row, with a stable code:
11
- * - `usage` (exit 2) — no `--file`, no `--reason`, or a path that is not a normalised
12
- * repo-root-relative path;
11
+ * - `usage` (exit 2) — no `--file`, no `--reason`, or a path with a control character or that is
12
+ * not a normalised repo-root-relative path (`checkFileForm`, shared with `block waive`);
13
13
  * - `unknown_block` — the run does not know the block (the code `block waive` uses);
14
14
  * - `reopen-block-not-open` — the block is not open (stopped or closed);
15
15
  * - `reopen-not-owned` — the block does not own the file;
@@ -42,9 +42,9 @@
42
42
  *
43
43
  * This file imports nothing from `src/worker/`: the fix-loop state is read through `io`.
44
44
  */
45
- import path from 'node:path';
46
45
  import { StateError } from '../state/paths.mjs';
47
46
  import { verifyRow } from '../state/signer.mjs';
47
+ import { checkFileForm } from './verb-forms.mjs';
48
48
 
49
49
  export const REOPENED_EVENT = 'review.reopened';
50
50
 
@@ -177,9 +177,7 @@ export function reopenRowFor(rows, { runId, block, file, key, status, state, seq
177
177
  export async function reopenFile({ runId, id, file, reason, record, rows, key, writeRow, writeSigned, io }) {
178
178
  if (typeof file !== 'string' || file.length === 0) throw new StateError('usage', 'block reopen needs --file <path>');
179
179
  if (typeof reason !== 'string' || reason.trim().length === 0) throw new StateError('usage', 'block reopen needs --reason');
180
- if (path.isAbsolute(file) || file.split('/').includes('..') || file.startsWith('./') || path.posix.normalize(file) !== file) {
181
- throw new StateError('usage', '--file must be repo-root-relative');
182
- }
180
+ checkFileForm(file, { normalized: true });
183
181
  const entry = openBlockEntry(record, runId, id);
184
182
  const owned = Array.isArray(entry.owned_files) ? entry.owned_files : [];
185
183
  if (!io.owns(owned, file, entry.owned_kinds)) throw new StateError('reopen-not-owned', `reopen-not-owned: block ${id} does not own ${file}; nothing was recorded`);
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Size-based review routing (block B90): `review.routes` sends a big file to a named model with
3
+ * its own session timeout and its own second level, with no `.code-forge.yml` edit and no reload.
4
+ *
5
+ * A rule is `{min_lines, level?, model, provider?, effort?, fallback?, session_timeout_s?, second?}`.
6
+ * `level` is the level it replaces (`L2` default, or `L3`, the judge). The worker picks the route
7
+ * once per ticket, before any session, from the line count of the file's content in the reviewed
8
+ * tree (the bytes it hashed). A deleted file has no content and is never routed. Among the rules
9
+ * of one level, the one with the largest `min_lines` that is at most the count wins.
10
+ *
11
+ * The routed ticket runs with a derived config: `levels.<level>` = the rule's model entry
12
+ * (`model`, `provider`, `effort`, `fallback`; with no `provider` the top-level one, like any level
13
+ * entry), `review.second_levels.<level>` = `second` when given — except an L2 rule while
14
+ * `review.multimodel` is on: the consensus second reviewer is `review.second_levels.L2`, so it is
15
+ * kept. The timeout is ONE value for every session of the ticket: the largest `session_timeout_s`
16
+ * among the matched rules that set one, else the unrouted timeout (`review.session_timeout_s`).
17
+ *
18
+ * No `review.routes` (or no rule that matches) leaves the config object itself untouched. Pure:
19
+ * no I/O; the worker (`src/worker/loop.mjs`) reads the bytes, writes the signed `review.route` row
20
+ * and pins the timeout. This module never imports `src/worker`.
21
+ */
22
+
23
+ import { resolveLevel } from '../config/known-ids.mjs';
24
+
25
+ /** The levels a rule may replace: the reviewers (L2) and the judge (L3). */
26
+ export const ROUTE_LEVELS = Object.freeze(['L2', 'L3']);
27
+
28
+ /** The level a rule replaces when it names none. */
29
+ export const DEFAULT_ROUTE_LEVEL = 'L2';
30
+
31
+ /** The keys of a rule that form the routed level entry (`$defs/level`). */
32
+ const ENTRY_KEYS = Object.freeze(['model', 'provider', 'effort', 'fallback']);
33
+
34
+ /** A route list that cannot be used: the ticket is `unavailable` with this code, never reviewed on another model. */
35
+ export class RouteError extends Error {
36
+ constructor() {
37
+ super('review.routes is not a list of valid rules');
38
+ this.code = 'route-invalid';
39
+ }
40
+ }
41
+
42
+ /**
43
+ * The number of lines of a file's content: its newline bytes, plus one for a last line with no
44
+ * newline. Counted on the bytes, never on decoded text. `null` (a deleted file) and an empty file
45
+ * have 0 lines.
46
+ * @param {Buffer | null} bytes @returns {number}
47
+ */
48
+ export function countLines(bytes) {
49
+ if (bytes === null || bytes.length === 0) return 0;
50
+ let n = 0;
51
+ for (let at = bytes.indexOf(0x0a); at !== -1; at = bytes.indexOf(0x0a, at + 1)) n += 1;
52
+ return bytes[bytes.length - 1] === 0x0a ? n : n + 1;
53
+ }
54
+
55
+ /** @param {unknown} v @returns {v is Record<string, any>} */
56
+ const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
57
+
58
+ /** @param {unknown} v @returns {boolean} */
59
+ const isPositiveInt = (v) => Number.isInteger(v) && /** @type {number} */ (v) >= 1;
60
+
61
+ /** @param {unknown} v @returns {boolean} a non-empty string. */
62
+ const isText = (v) => typeof v === 'string' && v.length > 0;
63
+
64
+ /** @param {unknown} v @returns {boolean} absent, or a string (the schema's `effort`/`provider` types). */
65
+ const optString = (v) => v === undefined || typeof v === 'string';
66
+
67
+ /**
68
+ * A fallback item (`$defs/level` fallback): an object with a model and a provider, and an
69
+ * `effort` string when present.
70
+ * @param {unknown} v @returns {boolean}
71
+ */
72
+ const isFallbackItem = (v) => isObject(v) && isText(v.model) && isText(v.provider) && optString(v.effort);
73
+
74
+ /**
75
+ * A `$defs/level`-shaped entry: a model string, `provider` and `effort` strings when present, and
76
+ * `fallback` (when present) a list of fallback items. Used for a rule and for its `second`.
77
+ * @param {unknown} v @returns {boolean}
78
+ */
79
+ function isEntry(v) {
80
+ if (!isObject(v) || !isText(v.model) || !optString(v.provider) || !optString(v.effort)) return false;
81
+ return v.fallback === undefined || (Array.isArray(v.fallback) && v.fallback.every(isFallbackItem));
82
+ }
83
+
84
+ /**
85
+ * Is `rule` usable? The schema and config rule 18 refuse everything else at `run start` and
86
+ * `run reload`; this guards a snapshot that never went through them.
87
+ * @param {unknown} rule @returns {boolean}
88
+ */
89
+ function usable(rule) {
90
+ if (!isEntry(rule)) return false;
91
+ const r = /** @type {Record<string, any>} */ (rule);
92
+ if (!Number.isInteger(r.min_lines) || r.min_lines < 0) return false;
93
+ if (r.level !== undefined && !ROUTE_LEVELS.includes(r.level)) return false;
94
+ if (r.session_timeout_s !== undefined && !isPositiveInt(r.session_timeout_s)) return false;
95
+ if (r.second !== undefined && !isEntry(r.second)) return false;
96
+ return true;
97
+ }
98
+
99
+ /** @param {Record<string, any>} rule @returns {Record<string, any>} the level entry a rule routes to. */
100
+ function entryOf(rule) {
101
+ /** @type {Record<string, any>} */
102
+ const entry = {};
103
+ for (const key of ENTRY_KEYS) if (rule[key] !== undefined) entry[key] = rule[key];
104
+ return entry;
105
+ }
106
+
107
+ /**
108
+ * The provider a rule's routed level runs on — the one place it is decided, shared with config
109
+ * validation (rules 16 and 18) so the two cannot drift: the rule's entry REPLACES
110
+ * `levels.<level>`, so a rule with no `provider` inherits the top-level `provider` (never the
111
+ * replaced level's own `provider`), resolved exactly as the spawner resolves any level.
112
+ * @param {Record<string, any> | null | undefined} cfg @param {Record<string, any>} rule
113
+ * @returns {string | undefined} undefined when the routed level cannot be resolved.
114
+ */
115
+ export function ruleProvider(cfg, rule) {
116
+ const level = rule?.level ?? DEFAULT_ROUTE_LEVEL;
117
+ const levels = { ...(isObject(cfg?.levels) ? cfg?.levels : {}), [level]: isObject(rule) ? entryOf(rule) : {} };
118
+ try {
119
+ return resolveLevel({ ...(cfg ?? {}), levels }, /** @type {any} */ (level)).provider;
120
+ } catch {
121
+ return undefined;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * The rules of `cfg.review.routes`: `[]` when the key is absent or null.
127
+ * @param {Record<string, any> | null | undefined} cfg
128
+ * @returns {Array<Record<string, any>>}
129
+ * @throws {RouteError} the key is set but is not a list of usable rules.
130
+ */
131
+ export function routeRules(cfg) {
132
+ const routes = cfg?.review?.routes;
133
+ if (routes === undefined || routes === null) return [];
134
+ if (!Array.isArray(routes) || !routes.every(usable)) throw new RouteError();
135
+ // config rule 18 refuses the same level and min_lines twice; a snapshot that skipped it fails
136
+ // closed here rather than letting list order pick the rule
137
+ const seen = new Set(routes.map((r) => `${r.level ?? DEFAULT_ROUTE_LEVEL}\0${r.min_lines}`));
138
+ if (seen.size !== routes.length) throw new RouteError();
139
+ return routes;
140
+ }
141
+
142
+ /**
143
+ * The winning rule per level for a file of `lines` lines: among the rules of a level, the largest
144
+ * `min_lines` that is at most `lines`. A deleted file (`deleted: true`) is never routed.
145
+ * @param {Record<string, any> | null | undefined} cfg @param {number} lines - a non-negative integer.
146
+ * @param {{deleted?: boolean}} [opts]
147
+ * @returns {Array<{level: string, rule: Record<string, any>}>} in `ROUTE_LEVELS` order; `[]` ⇒ no route.
148
+ * @throws {RouteError}
149
+ */
150
+ export function pickRoutes(cfg, lines, { deleted = false } = {}) {
151
+ const rules = routeRules(cfg);
152
+ // a count that is not a line count never picks (or skips) a route: fail closed
153
+ if (!Number.isInteger(lines) || lines < 0) throw new RouteError();
154
+ if (deleted || rules.length === 0) return [];
155
+ /** @type {Array<{level: string, rule: Record<string, any>}>} */
156
+ const out = [];
157
+ for (const level of ROUTE_LEVELS) {
158
+ let best = null;
159
+ for (const rule of rules) {
160
+ if ((rule.level ?? DEFAULT_ROUTE_LEVEL) !== level || rule.min_lines > lines) continue;
161
+ if (best === null || rule.min_lines > best.min_lines) best = rule;
162
+ }
163
+ if (best !== null) out.push({ level, rule: best });
164
+ }
165
+ return out;
166
+ }
167
+
168
+ /**
169
+ * @typedef {object} RoutedTicket
170
+ * @property {Record<string, any>} cfg - the config every session of the ticket runs with; the SAME
171
+ * object as the input when nothing is routed.
172
+ * @property {number} timeoutS - the session timeout of every session of the ticket.
173
+ * @property {Array<Record<string, any>>} rows - one `review.route` row body per routed level
174
+ * (`{event, level, min_lines, lines, provider, model, session_timeout_s, second_model}`), unsigned.
175
+ */
176
+
177
+ /**
178
+ * The ticket's routed config, timeout and `review.route` rows.
179
+ * @param {Record<string, any>} cfg - the ticket's config snapshot.
180
+ * @param {{lines: number, deleted?: boolean, timeoutS: number}} file - `timeoutS`: the unrouted timeout.
181
+ * @returns {RoutedTicket}
182
+ * @throws {RouteError}
183
+ */
184
+ export function routeTicket(cfg, { lines, deleted = false, timeoutS }) {
185
+ const picked = pickRoutes(cfg, lines, { deleted });
186
+ if (picked.length === 0) return { cfg, timeoutS, rows: [] };
187
+ const given = picked.map(({ rule }) => rule.session_timeout_s).filter((t) => t !== undefined);
188
+ const routedTimeout = given.length > 0 ? Math.max(...given) : timeoutS;
189
+ const levels = { ...(isObject(cfg.levels) ? cfg.levels : {}) };
190
+ const review = { ...(isObject(cfg.review) ? cfg.review : {}) };
191
+ /** @type {Record<string, any>} */
192
+ const seconds = { ...(isObject(review.second_levels) ? review.second_levels : {}) };
193
+ let secondsChanged = false;
194
+ /** @type {Array<{level: string, rule: Record<string, any>, second: boolean}>} */
195
+ const applied = [];
196
+ for (const { level, rule } of picked) {
197
+ levels[level] = entryOf(rule);
198
+ // the consensus second reviewer is `second_levels.L2`: an L2 rule never replaces it then
199
+ const second = rule.second !== undefined && !(level === 'L2' && review.multimodel === true);
200
+ if (second) {
201
+ seconds[level] = rule.second;
202
+ secondsChanged = true;
203
+ }
204
+ applied.push({ level, rule, second });
205
+ }
206
+ if (secondsChanged) review.second_levels = seconds;
207
+ const routed = { ...cfg, levels, review };
208
+ const rows = applied.map(({ level, rule, second }) => ({
209
+ event: 'review.route',
210
+ level,
211
+ min_lines: rule.min_lines,
212
+ lines,
213
+ provider: providerOf(cfg, rule),
214
+ model: rule.model,
215
+ session_timeout_s: routedTimeout,
216
+ second_model: second ? rule.second.model : null,
217
+ }));
218
+ return { cfg: routed, timeoutS: routedTimeout, rows };
219
+ }
220
+
221
+ /**
222
+ * The provider the routed level resolves to — the one its sessions spawn on (`ruleProvider`).
223
+ * @param {Record<string, any>} cfg - the unrouted snapshot. @param {Record<string, any>} rule
224
+ * @returns {string}
225
+ * @throws {RouteError} the routed level cannot be resolved (e.g. no provider anywhere): the ticket
226
+ * is `route-invalid`, never a row with no provider.
227
+ */
228
+ function providerOf(cfg, rule) {
229
+ const provider = ruleProvider(cfg, rule);
230
+ if (provider === undefined) throw new RouteError();
231
+ return provider;
232
+ }
@@ -1,5 +1,6 @@
1
1
  /**
2
- * The second-level retry after repeated `schema` misses on ONE packet (block B55, issue #2).
2
+ * The second-level retry after two broken results on ONE packet (block B55, issue #2): `schema`
3
+ * misses and, since B91, timed-out attempts, in any mix.
3
4
  *
4
5
  * Field data: one L2 reviewer model answered `unavailable: schema` four times in a row on the same
5
6
  * packet (round 1, its retry, a patch_check recheck, a fresh review), while the model configured in
@@ -13,8 +14,7 @@
13
14
  * `history`, which only counts rows whose MAC verifies) plus the miss in hand. In practice: the
14
15
  * round's first session is miss 1; the worker's existing retry of that round (`next: retry`, the
15
16
  * 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
17
+ * Each timed-out attempt counts too (B91, below); a `needs_file` round or a Markdown section is another packet text, so another count, and
18
18
  * so is a recheck whose pending set changed between tries (S1 `resolved` closed another finding,
19
19
  * so the open-findings list differs): a different packet starts its own count, by design.
20
20
  *
@@ -43,6 +43,19 @@
43
43
  * schema-side params, Ajv's message), for non-JSON the kind of parse error and its byte offset.
44
44
  * Text a model copies from the packet into a JSON string can never reach the ledger this way.
45
45
  *
46
+ * B91: a timed-out attempt is a broken result too. Every timed-out attempt of the level's own
47
+ * ladder leaves one signed `review.session_timeout` note with the packet's `packet_hash`
48
+ * (`session-retry.mjs`), so the count above is `review.schema_invalid` rows plus those notes
49
+ * (`second_level` not true) plus the miss in hand, schema and timeout in any mix. After each
50
+ * timed-out attempt `session-retry.mjs` asks {@link afterSchemaMiss} with `trigger: 'timeout'`:
51
+ * its note is already in the ledger (it IS the miss in hand, so nothing more is written or added),
52
+ * and at 2 the second level is tried in that same ticket instead of the same-model retry. The mark
53
+ * says which kind of broken result was the second one: `trigger: schema | timeout`; an old mark
54
+ * without `trigger` reads as `schema`. A note without `packet_hash` (written before B91), an
55
+ * unsigned note and a `second_level` note are read but never count. With no second level nothing
56
+ * is tried and no mark is written: the timeout is retried once on the same model as before, its
57
+ * note now carrying `packet_hash` and signed.
58
+ *
46
59
  * Concurrency: the worker never runs two tickets for one (block, file) at once (its file lock,
47
60
  * `worker/loop.mjs`), and round 1, its retry, every recheck and the patch_check of a file are all
48
61
  * tickets for that file, so one packet's history is read and written by one ticket at a time. The
@@ -61,7 +74,7 @@ import { logWarning } from '../util/error-log.mjs';
61
74
  import { keyedLock } from '../util/locks.mjs';
62
75
  import { schemaErrorsOf } from './validate-review.mjs';
63
76
 
64
- /** Schema misses of the level's own ladder on one packet before the second level is tried. */
77
+ /** Broken results (B91: schema misses and timeouts) of the level's own ladder on one packet before the second level is tried. */
65
78
  export const SCHEMA_FAILURES_BEFORE_FALLBACK = 2;
66
79
 
67
80
  /** At most this many schema errors are kept in a diagnosis (`errors_total` has the full count). */
@@ -78,8 +91,9 @@ const KEPT_PARAMS = Object.freeze(['missingProperty', 'additionalProperty', 'typ
78
91
  /**
79
92
  * @typedef {(packetHash: string, who: {level: string, role: string}) => Promise<{failures: number, fellBack: boolean}>} SchemaHistory -
80
93
  * 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
94
+ * file at this content hash: `failures` = `review.schema_invalid` and (B91)
95
+ * `review.session_timeout` rows of the level's own ladder (`second_level` not true),
96
+ * `fellBack` = a `review.schema_fallback` row exists. Built by the
83
97
  * worker; absent ⇒ no second-level try.
84
98
  */
85
99
 
@@ -218,8 +232,24 @@ export function secondLevelFor(cfg, level, from, role) {
218
232
  }
219
233
 
220
234
  /**
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).
235
+ * Whether a {@link secondLevelFor} answer says no second level is configured (a `same-model` or
236
+ * `closed-book` refusal is a configured level that cannot run: its mark is still written).
237
+ * @param {ReturnType<typeof secondLevelFor>} second @returns {boolean}
238
+ */
239
+ function noSecondLevel(second) {
240
+ return second.ok === false && second.reason === 'no-second-level';
241
+ }
242
+
243
+ /** @param {Record<string, any>} row @returns {'schema' | 'timeout'} what a mark says the second broken result was; an old mark without `trigger` is `schema`. */
244
+ export function fallbackTrigger(row) {
245
+ return row?.trigger === 'timeout' ? 'timeout' : 'schema';
246
+ }
247
+
248
+ /**
249
+ * Log a schema miss and, when it is the packet's second broken result (see the module doc), try the
250
+ * second level once. B91: `trigger: 'timeout'` is a timed-out attempt whose signed note is already
251
+ * written (nothing more is logged, and the history already counts it). Never throws (see the
252
+ * module doc).
223
253
  * @param {object} p
224
254
  * @param {Record<string, any> | null} p.res - the missed session's result.
225
255
  * @param {{lens: string, role: string, level: string}} p.session
@@ -229,21 +259,41 @@ export function secondLevelFor(cfg, level, from, role) {
229
259
  * @param {SchemaHistory | undefined} p.history
230
260
  * @param {((row: Record<string, any>) => Promise<unknown>) | undefined} p.writeRow - throws on a
231
261
  * 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).
262
+ * @param {(cfg: Record<string, any>, to: {level: string, provider: string, model: string}) => Promise<{res: Record<string, any> | null, attempts: number}>} p.spawnAt
263
+ * - the same session on the same packet with another config (timeout retry included); `to`
264
+ * says which second level it is (called only after the mark row was written).
265
+ * @param {'schema' | 'timeout'} [p.trigger] - B91: the kind of the miss in hand (default `schema`).
234
266
  * @returns {Promise<{res: Record<string, any> | null, attempts: number, to: {level: string, provider: string, model: string}} | null>}
235
267
  * the second level's result, or null when none was spawned.
236
268
  */
237
- export async function afterSchemaMiss({ res, session, packetText, cfg, allowed, history, writeRow, spawnAt }) {
269
+ export async function afterSchemaMiss({ res, session, packetText, cfg, allowed, history, writeRow, spawnAt, trigger = 'schema' }) {
238
270
  if (!writeRow) return null;
271
+ const timedOut = trigger === 'timeout';
272
+ // a timeout's note is written by the retry helper: where no try can be made there is nothing to
273
+ // do — no history read, no row (B91: with no second level a timeout runs exactly as before B30)
274
+ if (timedOut && (!allowed || !history)) return null;
239
275
  const write = writeRow;
240
276
  const hash = packetHash(packetText);
241
277
  const from = { provider: typeof res?.provider === 'string' ? res.provider : null, model: typeof res?.model === 'string' ? res.model : null };
278
+ if (timedOut && (from.provider === null || from.model === null)) {
279
+ // B91: a timed-out result may lack its model; it is the level's resolved one (what was spawned)
280
+ try {
281
+ const resolved = resolveLevel(cfg, /** @type {any} */ (session.level));
282
+ from.provider = from.provider ?? resolved.provider;
283
+ from.model = from.model ?? resolved.model;
284
+ } catch {
285
+ // an unresolvable level: the mark keeps null (the spawn itself could not have resolved it)
286
+ }
287
+ }
288
+ // no second level for a timeout: nothing read, nothing written. Asked with the SAME arguments as
289
+ // the decision below (cfg, level, from, role), so this guard can never disagree with it
290
+ if (timedOut && noSecondLevel(secondLevelFor(cfg, session.level, from, session.role))) return null;
242
291
  /** @type {{cfg: Record<string, any>, to: {level: string, provider: string, model: string}} | null} */
243
292
  let go = null;
244
293
  try {
245
294
  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
295
+ // the history is read BEFORE a schema miss is logged: failures = signed earlier misses + this
296
+ // one. A timeout's note is already in it (B91): it is the miss in hand, never added twice.
247
297
  let seen = null;
248
298
  if (allowed && history) {
249
299
  try {
@@ -253,18 +303,21 @@ export async function afterSchemaMiss({ res, session, packetText, cfg, allowed,
253
303
  await logWarning({ warning: 'review_schema_history', message: 'the schema-miss history could not be read; no second-level try' }).catch(() => null);
254
304
  }
255
305
  }
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)
306
+ if (!timedOut) {
307
+ try {
308
+ 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) });
309
+ } catch {
310
+ // the log is best-effort; the count then misses this one (fewer tries, never more)
311
+ }
260
312
  }
261
- if (!seen || seen.fellBack || seen.failures + 1 < SCHEMA_FAILURES_BEFORE_FALLBACK) return null;
313
+ const broken = seen ? seen.failures + (timedOut ? 0 : 1) : 0;
314
+ if (!seen || seen.fellBack || broken < SCHEMA_FAILURES_BEFORE_FALLBACK) return null;
262
315
  const second = secondLevelFor(cfg, session.level, from, session.role);
263
316
  const to = `review.second_levels.${session.level}`;
264
317
  /** @type {string | null} */
265
318
  let refused = null;
266
319
  if (second.ok === false) {
267
- if (second.reason === 'no-second-level') return null;
320
+ if (noSecondLevel(second)) return null;
268
321
  refused = second.reason; // `same-model` or `closed-book`: nothing of the level may run
269
322
  }
270
323
  await write({
@@ -278,7 +331,8 @@ export async function afterSchemaMiss({ res, session, packetText, cfg, allowed,
278
331
  to,
279
332
  to_provider: second.ok ? second.provider : null,
280
333
  to_model: second.ok ? second.model : null,
281
- attempts: seen.failures + 1,
334
+ attempts: broken,
335
+ trigger,
282
336
  ...(refused ? { refused } : {}),
283
337
  }); // the once-per-packet mark: a failed write throws ⇒ no try
284
338
  return !second.ok ? null : { cfg: second.cfg, to: { level: to, provider: second.provider, model: second.model } };
@@ -287,9 +341,9 @@ export async function afterSchemaMiss({ res, session, packetText, cfg, allowed,
287
341
  return null;
288
342
  }
289
343
  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);
344
+ await logWarning({ warning: 'review_schema_fallback', message: 'a review broke twice on one packet (schema or timeout); the second level was tried once' }).catch(() => null);
291
345
  try {
292
- const out = await spawnAt(go.cfg);
346
+ const out = await spawnAt(go.cfg, go.to);
293
347
  return { res: out.res, attempts: out.attempts, to: go.to };
294
348
  } catch {
295
349
  // a throwing spawn is a session with no result: `exit` through the stub guard, never approval
@@ -326,6 +380,7 @@ export async function logSecondLevelMiss({ res, session, packetText, writeRow })
326
380
  * about, rows of `runId` for `file` at `contentHash` with that `packet_hash`, `level` and `role`
327
381
  * whose MAC verifies (`verify`; a row that does not verify, or whose check throws, counts for
328
382
  * nothing). A miss on another packet of the same content, or at another level, never counts.
383
+ * B91: a signed `review.session_timeout` note with that `packet_hash` counts like a schema miss.
329
384
  * @param {{readRows: () => Promise<Array<Record<string, any>>>, verify: (row: Record<string, any>) => boolean, runId: string, file: string, contentHash: string}} opts
330
385
  * @returns {SchemaHistory}
331
386
  */
@@ -335,7 +390,7 @@ export function ledgerSchemaHistory({ readRows, verify, runId, file, contentHash
335
390
  let failures = 0;
336
391
  let fellBack = false;
337
392
  for (const r of rows) {
338
- if (r.event !== 'review.schema_invalid' && r.event !== 'review.schema_fallback') continue;
393
+ if (r.event !== 'review.schema_invalid' && r.event !== 'review.session_timeout' && r.event !== 'review.schema_fallback') continue;
339
394
  let ok = false;
340
395
  try {
341
396
  ok = verify(r);
@@ -9,11 +9,26 @@
9
9
  *
10
10
  * Both attempts are recorded: `spawnSession` writes one ledger `session` row per spawn as always,
11
11
  * and every timed-out attempt adds a `review.session_timeout` note `{lens, role, level, provider,
12
- * model, attempt, retried, duration_ms, stderr_tail, tail_note?, stdout_bytes, stdout_events}`:
13
- * the last 4 KB of the session's stderr, packet-free and redacted (`stderrTail`; null with a
14
- * `tail_note` when withheld), and stdout only as its byte count and last event types
15
- * (`stdoutEvents`) — never stdout text. The helper never throws: a throwing spawn is returned as
16
- * `res: null` and a failed note is ignored. `onAttempt(n)` is told each attempt as it starts.
12
+ * model, attempt, retried, duration_ms, stderr_tail, tail_note?, stdout_bytes, stdout_events,
13
+ * packet_hash?, second_level?}`: the last 4 KB of the session's stderr, packet-free and redacted
14
+ * (`stderrTail`; null with a `tail_note` when withheld), and stdout only as its byte count and last
15
+ * event types (`stdoutEvents`) — never stdout text. A throwing spawn is returned as `res: null` and
16
+ * a failed note is ignored; the helper throws only when `onBroken` throws (B91: rethrown, never
17
+ * followed by the same-model retry — the caller turns it into a non-approval). `onAttempt(n)` is told each attempt as it
18
+ * starts.
19
+ *
20
+ * B91: a timed-out attempt is a BROKEN result, like a schema miss. The caller passes the packet's
21
+ * hash (`packetHash`, sha256 of the packet text): every note then carries `packet_hash`, so the
22
+ * worker's signed history (`ledgerSchemaHistory`) counts it for that packet, level and role. In the
23
+ * worker every note goes through its ledger writer, which signs it with the run key. A session of
24
+ * the second-level try itself passes `secondLevel: true`: its notes say `second_level: true` and
25
+ * never count. `onBroken(res)` (never used on a second-level try) is asked after each timed-out
26
+ * attempt's note is written: null ⇒ the B30 flow goes on unchanged (retry once, then give up); a
27
+ * result ⇒ the packet's second broken result started the B55 second-level try
28
+ * (`schema-fallback.mjs`, `trigger: timeout`) and its outcome is the session's:
29
+ * `{res, attempts: n + its attempts, to, broken}` (`broken`: the timed-out result it replaced, for
30
+ * the token sum). The note's `retried` keeps its B30 meaning (the same-model retry was due); the
31
+ * `review.schema_fallback` row written after it says the second level ran instead.
17
32
  */
18
33
 
19
34
  import { logWarning } from '../util/error-log.mjs';
@@ -21,15 +36,25 @@ import { logWarning } from '../util/error-log.mjs';
21
36
  /** How many times a timed-out session is spawned again. */
22
37
  export const TIMEOUT_RETRIES = 1;
23
38
 
39
+ /**
40
+ * @typedef {{res: Record<string, any> | null, attempts: number, to: {level: string, provider: string, model: string}}} BrokenTry -
41
+ * the outcome of a second-level try started by a timed-out attempt (`afterSchemaMiss`).
42
+ * @typedef {{packetHash?: string, secondLevel?: boolean, onBroken?: (res: Record<string, any>) => Promise<BrokenTry | null>}} BrokenOpts -
43
+ * B91: the packet hash every note carries, whether this is the second-level try, and the
44
+ * broken-result hook (see the module doc).
45
+ */
46
+
24
47
  /**
25
48
  * @param {(opts: Record<string, any>) => Promise<Record<string, any>>} spawn
26
49
  * @param {Record<string, any>} opts - the spawn options; passed unchanged to every attempt.
27
50
  * @param {(row: Record<string, any>) => Promise<unknown>} note - best-effort ledger note.
28
51
  * @param {(attempt: number) => void} [onAttempt] - told the attempt number as each one starts.
29
- * @returns {Promise<{res: Record<string, any> | null, attempts: number}>} the last attempt's result
30
- * (`null` when the spawn threw) and how many attempts ran.
52
+ * @param {BrokenOpts} [broken] - B91 (absent ⇒ the B30 behaviour, notes without `packet_hash`).
53
+ * @returns {Promise<{res: Record<string, any> | null, attempts: number, to?: BrokenTry['to'], broken?: Record<string, any>}>}
54
+ * the last attempt's result (`null` when the spawn threw) and how many attempts ran; with `to`
55
+ * (and `broken`) when a timed-out attempt started the second-level try.
31
56
  */
32
- export async function spawnWithTimeoutRetry(spawn, opts, note, onAttempt = () => {}) {
57
+ export async function spawnWithTimeoutRetry(spawn, opts, note, onAttempt = () => {}, broken = {}) {
33
58
  let attempts = 0;
34
59
  for (;;) {
35
60
  attempts += 1;
@@ -58,9 +83,19 @@ export async function spawnWithTimeoutRetry(spawn, opts, note, onAttempt = () =>
58
83
  ...(typeof res.tail_note === 'string' ? { tail_note: res.tail_note } : {}),
59
84
  stdout_bytes: typeof res.stdout_bytes === 'number' ? res.stdout_bytes : null,
60
85
  stdout_events: Array.isArray(res.stdout_events) ? res.stdout_events : [],
86
+ ...(typeof broken.packetHash === 'string' ? { packet_hash: broken.packetHash } : {}),
87
+ ...(broken.secondLevel === true ? { second_level: true } : {}),
61
88
  });
62
89
  } catch {
63
- // a failed note never stops the retry or the result
90
+ // a failed note never stops the retry or the result (the count then misses it: fewer tries)
91
+ }
92
+ if (broken.onBroken && broken.secondLevel !== true) {
93
+ // Contract: `onBroken` is called AFTER this attempt's note was written (or its write failed),
94
+ // so the hook's count already holds the attempt in hand and must not add it a second time.
95
+ // A hook that throws is rethrown, never followed by the B30 same-model retry: a second-level
96
+ // try may already have started, and the caller turns the throw into a non-approval.
97
+ const taken = await broken.onBroken(res);
98
+ if (taken) return { res: taken.res, attempts: attempts + taken.attempts, to: taken.to, broken: res };
64
99
  }
65
100
  if (!retried) return { res, attempts };
66
101
  // B37: the retry is a recoverable problem: one warning line in the local error log