@codedology/code-forge 0.3.2 → 0.5.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.
- package/CHANGELOG.md +85 -0
- package/README.md +88 -21
- package/bin/code-forge.mjs +6 -1
- package/docs/README.md +5 -4
- package/docs/reference/blocks.json +236 -1
- package/docs/reference/config.md +13 -1
- package/package.json +6 -2
- package/schema/code-forge.schema.json +26 -4
- package/skill/SKILL.md +8 -7
- package/skill/references/adapters/claude-code.md +5 -1
- package/skill/references/adapters/solo.md +1 -1
- package/skill/references/adapters/subprocess.md +1 -1
- package/skill/references/code.md +4 -3
- package/skill/references/continuity.md +4 -0
- package/skill/references/decisions.md +5 -2
- package/skill/references/ledger.md +10 -2
- package/skill/references/plan.md +12 -3
- package/skill/references/review.md +13 -6
- package/skill/references/security.md +3 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +36 -0
- package/skill/templates/plan.md +2 -0
- package/src/cli/block.mjs +51 -8
- package/src/cli/facts.mjs +16 -1
- package/src/cli/jev.mjs +114 -17
- package/src/cli/ledger.mjs +99 -8
- package/src/cli/logs.mjs +193 -58
- package/src/cli/plan.mjs +27 -11
- package/src/cli/report.mjs +15 -1
- package/src/cli/resolve.mjs +16 -1
- package/src/cli/review-file.mjs +10 -5
- package/src/cli/review.mjs +40 -9
- package/src/cli/run.mjs +109 -8
- package/src/config/closed-book.mjs +75 -0
- package/src/config/validate.mjs +128 -7
- package/src/decide/escalation.mjs +112 -11
- package/src/decide/questions.mjs +27 -4
- package/src/doctor/index.mjs +2 -1
- package/src/doctor/local.mjs +15 -0
- package/src/doctor/probes.mjs +48 -6
- package/src/engines/builders/claude.mjs +4 -1
- package/src/engines/builders/codex.mjs +26 -11
- package/src/engines/builders/grok.mjs +3 -3
- package/src/engines/efforts.mjs +109 -0
- package/src/engines/sentinel.mjs +34 -0
- package/src/keys/onepassword.mjs +3 -0
- package/src/ledger/report.mjs +66 -29
- package/src/ledger/spend.mjs +288 -0
- package/src/review/engine.mjs +57 -16
- package/src/review/fixloop.mjs +29 -6
- package/src/review/session-retry.mjs +69 -0
- package/src/review/triage.mjs +26 -1
- package/src/review/validate-review.mjs +6 -3
- package/src/session/author.mjs +6 -2
- package/src/session/facts.mjs +218 -53
- package/src/session/plan-check.mjs +71 -6
- package/src/session/plan-sections.mjs +98 -0
- package/src/session/spawn.mjs +373 -20
- package/src/state/block.mjs +13 -4
- package/src/state/config-snapshot.mjs +150 -0
- package/src/state/run.mjs +86 -0
- package/src/util/breadcrumbs.mjs +145 -0
- package/src/util/error-hint.mjs +3 -2
- package/src/util/error-kind.mjs +2 -2
- package/src/util/error-log.mjs +109 -18
- package/src/util/forbidden.mjs +3 -1
- package/src/util/known-fixes.json +1 -0
- package/src/util/known-fixes.mjs +153 -0
- package/src/util/locks.mjs +104 -0
- package/src/worker/engine.mjs +122 -40
- package/src/worker/loop.mjs +326 -78
- package/src/worker/queue.mjs +68 -7
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Known fixes (B38): a small table shipped in the package, `src/util/known-fixes.json`, that maps
|
|
3
|
+
* an error fingerprint (`util/error-log.mjs` `fingerprint`) to the version that fixed it.
|
|
4
|
+
*
|
|
5
|
+
* [{ "fp": "<12 hex>", "fixed_in": "x.y.z", "summary": "<one plain line>", "issue": <number|null> }]
|
|
6
|
+
*
|
|
7
|
+
* `code-forge logs` marks a known-fixed line `[fixed in x.y.z]`; `logs report` does not file an
|
|
8
|
+
* error that is fixed in a newer version than the one running (unless `--force`) and notes a
|
|
9
|
+
* possible regression when the running version already has the fix. The maintainer adds entries
|
|
10
|
+
* with `npm run known-fix -- add …` (`scripts/known-fix.mjs`, which uses {@link validateKnownFixes});
|
|
11
|
+
* `.github/workflows/error-triage.yml` reads the same file to comment on new error reports.
|
|
12
|
+
*
|
|
13
|
+
* Reading never throws: a missing or unreadable table is empty, and an entry that does not
|
|
14
|
+
* validate is left out (a broken table must never hide a report).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { readFileSync } from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
/** The shipped table. */
|
|
22
|
+
export const KNOWN_FIXES_FILE = path.join(path.dirname(fileURLToPath(import.meta.url)), 'known-fixes.json');
|
|
23
|
+
|
|
24
|
+
/** A summary longer than this is refused (it is one plain line). */
|
|
25
|
+
export const SUMMARY_MAX_CHARS = 200;
|
|
26
|
+
|
|
27
|
+
const FP_RE = /^[0-9a-f]{12}$/;
|
|
28
|
+
const VERSION_RE = /^(0|[1-9]\d{0,5})\.(0|[1-9]\d{0,5})\.(0|[1-9]\d{0,5})$/;
|
|
29
|
+
const KEYS = Object.freeze(['fp', 'fixed_in', 'summary', 'issue']);
|
|
30
|
+
|
|
31
|
+
/** @typedef {{fp: string, fixed_in: string, summary: string, issue: number|null}} KnownFix */
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Compare two semver versions: `x.y.z`, then a pre-release is older than its release
|
|
35
|
+
* (`1.2.0-beta.1` < `1.2.0`) and pre-releases compare part by part (numbers as numbers, numbers
|
|
36
|
+
* before words). Build metadata (`+…`) is ignored.
|
|
37
|
+
* @param {string} a @param {string} b
|
|
38
|
+
* @returns {number|null} >0 when a is newer, <0 when older, 0 when equal; null when unreadable.
|
|
39
|
+
*/
|
|
40
|
+
export function compareVersions(a, b) {
|
|
41
|
+
const re = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
42
|
+
const pa = typeof a === 'string' ? re.exec(a.trim()) : null;
|
|
43
|
+
const pb = typeof b === 'string' ? re.exec(b.trim()) : null;
|
|
44
|
+
if (!pa || !pb) return null;
|
|
45
|
+
for (let i = 1; i <= 3; i += 1) {
|
|
46
|
+
const d = Number(pa[i]) - Number(pb[i]);
|
|
47
|
+
if (d !== 0) return d;
|
|
48
|
+
}
|
|
49
|
+
if (pa[4] === undefined || pb[4] === undefined) return pa[4] === pb[4] ? 0 : pa[4] === undefined ? 1 : -1;
|
|
50
|
+
const xa = pa[4].split('.');
|
|
51
|
+
const xb = pb[4].split('.');
|
|
52
|
+
for (let i = 0; i < Math.max(xa.length, xb.length); i += 1) {
|
|
53
|
+
if (xa[i] === undefined) return -1;
|
|
54
|
+
if (xb[i] === undefined) return 1;
|
|
55
|
+
const na = /^\d+$/.test(xa[i]);
|
|
56
|
+
const nb = /^\d+$/.test(xb[i]);
|
|
57
|
+
if (na && nb) {
|
|
58
|
+
const d = Number(xa[i]) - Number(xb[i]);
|
|
59
|
+
if (d !== 0) return d;
|
|
60
|
+
} else if (na !== nb) {
|
|
61
|
+
return na ? -1 : 1;
|
|
62
|
+
} else if (xa[i] !== xb[i]) {
|
|
63
|
+
return xa[i] < xb[i] ? -1 : 1;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The problems of one entry, each naming the key only (never its value).
|
|
71
|
+
* @param {unknown} e
|
|
72
|
+
* @returns {string[]} empty when the entry is valid.
|
|
73
|
+
*/
|
|
74
|
+
export function entryProblems(e) {
|
|
75
|
+
if (e === null || typeof e !== 'object' || Array.isArray(e)) return ['not an object'];
|
|
76
|
+
const o = /** @type {Record<string, unknown>} */ (e);
|
|
77
|
+
const problems = [];
|
|
78
|
+
for (const k of Object.keys(o)) if (!KEYS.includes(k)) problems.push(`unknown key ${JSON.stringify(k)}`);
|
|
79
|
+
if (typeof o.fp !== 'string' || !FP_RE.test(o.fp)) problems.push('fp must be 12 lowercase hex characters');
|
|
80
|
+
if (typeof o.fixed_in !== 'string' || !VERSION_RE.test(o.fixed_in)) problems.push('fixed_in must be a version x.y.z');
|
|
81
|
+
if (typeof o.summary !== 'string' || o.summary.trim().length === 0) problems.push('summary must be a non-empty string');
|
|
82
|
+
else if (o.summary !== o.summary.trim() || /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/.test(o.summary)) problems.push('summary must be one plain line (no line breaks, tabs, control or text-direction characters, or leading/trailing spaces)');
|
|
83
|
+
else if ([...o.summary].length > SUMMARY_MAX_CHARS) problems.push(`summary must be at most ${SUMMARY_MAX_CHARS} characters`);
|
|
84
|
+
if (!Object.hasOwn(o, 'issue') || !(o.issue === null || (Number.isSafeInteger(o.issue) && /** @type {number} */ (o.issue) > 0))) {
|
|
85
|
+
problems.push('issue must be a positive issue number or null');
|
|
86
|
+
}
|
|
87
|
+
return problems;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Validate a whole table: an array of valid entries, each fingerprint once.
|
|
92
|
+
* @param {unknown} data
|
|
93
|
+
* @returns {string[]} the problems (`entry 2: fp must be …`); empty when the table is valid.
|
|
94
|
+
*/
|
|
95
|
+
export function validateKnownFixes(data) {
|
|
96
|
+
if (!Array.isArray(data)) return ['the table must be a JSON array'];
|
|
97
|
+
const problems = [];
|
|
98
|
+
/** @type {Map<string, number>} */
|
|
99
|
+
const seen = new Map();
|
|
100
|
+
data.forEach((e, i) => {
|
|
101
|
+
for (const p of entryProblems(e)) problems.push(`entry ${i + 1}: ${p}`);
|
|
102
|
+
const fp = /** @type {any} */ (e)?.fp;
|
|
103
|
+
if (typeof fp === 'string' && FP_RE.test(fp)) {
|
|
104
|
+
if (seen.has(fp)) problems.push(`entry ${i + 1}: fp is already listed in entry ${seen.get(fp)}`);
|
|
105
|
+
else seen.set(fp, i + 1);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
return problems;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The valid entries of the table at `file`, by fingerprint (first one wins). Never throws.
|
|
113
|
+
* @param {string} [file]
|
|
114
|
+
* @returns {Map<string, KnownFix>}
|
|
115
|
+
*/
|
|
116
|
+
export function loadKnownFixes(file = KNOWN_FIXES_FILE) {
|
|
117
|
+
let data;
|
|
118
|
+
try {
|
|
119
|
+
data = JSON.parse(readFileSync(file, 'utf8'));
|
|
120
|
+
} catch {
|
|
121
|
+
return new Map();
|
|
122
|
+
}
|
|
123
|
+
return knownFixMap(data);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The valid entries of `data`, by fingerprint (first one wins); anything else is left out.
|
|
128
|
+
* @param {unknown} data
|
|
129
|
+
* @returns {Map<string, KnownFix>}
|
|
130
|
+
*/
|
|
131
|
+
export function knownFixMap(data) {
|
|
132
|
+
/** @type {Map<string, KnownFix>} */
|
|
133
|
+
const out = new Map();
|
|
134
|
+
if (!Array.isArray(data)) return out;
|
|
135
|
+
for (const e of data) {
|
|
136
|
+
if (entryProblems(e).length > 0 || out.has(e.fp)) continue;
|
|
137
|
+
out.set(e.fp, { fp: e.fp, fixed_in: e.fixed_in, summary: e.summary, issue: e.issue });
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* How a known fix relates to the running version: `older` (the fix is in a newer version than the
|
|
144
|
+
* one running), `has-fix` (the running version is the fixed one or newer: a possible regression),
|
|
145
|
+
* or `unknown` (the running version cannot be read).
|
|
146
|
+
* @param {KnownFix} fix @param {string} running
|
|
147
|
+
* @returns {'older'|'has-fix'|'unknown'}
|
|
148
|
+
*/
|
|
149
|
+
export function fixStatus(fix, running) {
|
|
150
|
+
const c = compareVersions(running, fix.fixed_in);
|
|
151
|
+
if (c === null) return 'unknown';
|
|
152
|
+
return c < 0 ? 'older' : 'has-fix';
|
|
153
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process locks (parallel review, block B40): a keyed mutex and a counting semaphore.
|
|
3
|
+
*
|
|
4
|
+
* Both live in this process only — no files, no cross-process coordination — and use no timers,
|
|
5
|
+
* so a waiting lock never keeps the event loop alive on its own.
|
|
6
|
+
*
|
|
7
|
+
* `keyedLock(key, fn)`: calls for the same key run one at a time, in call order (FIFO); calls for
|
|
8
|
+
* different keys run together. A throw (or rejection) in `fn` releases the key and is passed on.
|
|
9
|
+
*
|
|
10
|
+
* `semaphore(n)`: `acquire()` resolves to a `release` function once fewer than `n` holders are
|
|
11
|
+
* active; waiters are served in order. `release` is idempotent (a second call does nothing).
|
|
12
|
+
* `run(fn)` acquires, runs `fn`, and releases on every exit path.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** @type {Map<unknown, Promise<void>>} the tail of each key's queue. */
|
|
16
|
+
const tails = new Map();
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Run `fn` once every earlier call for `key` has finished.
|
|
20
|
+
* @template T
|
|
21
|
+
* @param {unknown} key
|
|
22
|
+
* @param {() => T | Promise<T>} fn
|
|
23
|
+
* @returns {Promise<T>}
|
|
24
|
+
*/
|
|
25
|
+
export function keyedLock(key, fn) {
|
|
26
|
+
const prev = tails.get(key) ?? Promise.resolve();
|
|
27
|
+
/** @type {() => void} */
|
|
28
|
+
let release = () => {};
|
|
29
|
+
const done = new Promise((resolve) => {
|
|
30
|
+
release = () => resolve(undefined);
|
|
31
|
+
});
|
|
32
|
+
const tail = prev.then(() => done);
|
|
33
|
+
tails.set(key, tail);
|
|
34
|
+
return prev.then(async () => {
|
|
35
|
+
try {
|
|
36
|
+
return await fn();
|
|
37
|
+
} finally {
|
|
38
|
+
release();
|
|
39
|
+
if (tails.get(key) === tail) tails.delete(key);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @typedef {object} Semaphore
|
|
46
|
+
* @property {() => Promise<() => void>} acquire - resolves to an idempotent `release`.
|
|
47
|
+
* @property {<T>(fn: () => T | Promise<T>) => Promise<T>} run - `fn` under one slot, released on every exit path.
|
|
48
|
+
* @property {number} active - holders right now.
|
|
49
|
+
* @property {number} pending - waiters right now.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A counting semaphore: never more than `n` holders at once.
|
|
54
|
+
* @param {number} n - an integer ≥ 1.
|
|
55
|
+
* @returns {Semaphore}
|
|
56
|
+
*/
|
|
57
|
+
export function semaphore(n) {
|
|
58
|
+
if (!Number.isInteger(n) || n < 1) throw new TypeError('semaphore: n must be an integer >= 1');
|
|
59
|
+
let active = 0;
|
|
60
|
+
/** @type {Array<(release: () => void) => void>} */
|
|
61
|
+
const waiters = [];
|
|
62
|
+
|
|
63
|
+
/** @returns {() => void} */
|
|
64
|
+
function grant() {
|
|
65
|
+
active += 1;
|
|
66
|
+
let released = false;
|
|
67
|
+
return () => {
|
|
68
|
+
if (released) return;
|
|
69
|
+
released = true;
|
|
70
|
+
active -= 1;
|
|
71
|
+
const next = waiters.shift();
|
|
72
|
+
if (next) next(grant());
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** @returns {Promise<() => void>} */
|
|
77
|
+
function acquire() {
|
|
78
|
+
if (active < n && waiters.length === 0) return Promise.resolve(grant());
|
|
79
|
+
return new Promise((resolve) => {
|
|
80
|
+
waiters.push(resolve);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** @template T @param {() => T | Promise<T>} fn @returns {Promise<T>} */
|
|
85
|
+
async function run(fn) {
|
|
86
|
+
const release = await acquire();
|
|
87
|
+
try {
|
|
88
|
+
return await fn();
|
|
89
|
+
} finally {
|
|
90
|
+
release();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
acquire,
|
|
96
|
+
run,
|
|
97
|
+
get active() {
|
|
98
|
+
return active;
|
|
99
|
+
},
|
|
100
|
+
get pending() {
|
|
101
|
+
return waiters.length;
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
package/src/worker/engine.mjs
CHANGED
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
|
|
37
37
|
import { rmSync } from 'node:fs';
|
|
38
38
|
import path from 'node:path';
|
|
39
|
+
import { blockKind } from '../decide/escalation.mjs';
|
|
39
40
|
import { askJev } from '../decide/jev-client.mjs';
|
|
40
41
|
import { planAndRecord, tierOf } from '../review/budget.mjs';
|
|
41
42
|
import { budgetFor } from '../review/packet.mjs';
|
|
@@ -44,7 +45,8 @@ import { computeFileSet, ownsFile } from '../gates/scope.mjs';
|
|
|
44
45
|
import { newFileState, runRound } from '../review/fixloop.mjs';
|
|
45
46
|
import { readRun } from '../state/run.mjs';
|
|
46
47
|
import { redact } from '../util/redact.mjs';
|
|
47
|
-
import {
|
|
48
|
+
import { keyedLock } from '../util/locks.mjs';
|
|
49
|
+
import { blockAnchors, blockRungUsed, loadState, saveState } from './review-state.mjs';
|
|
48
50
|
import { assertTicketId } from './ticket.mjs';
|
|
49
51
|
|
|
50
52
|
/**
|
|
@@ -89,14 +91,17 @@ import { assertTicketId } from './ticket.mjs';
|
|
|
89
91
|
* `block-unknown`; a record that cannot be read or parsed throws `run-record-unreadable`, and a
|
|
90
92
|
* block entry without a base `block-base-missing` — a guessed base could let a change escape review.
|
|
91
93
|
* @param {string} runId @param {string} block
|
|
92
|
-
*
|
|
94
|
+
* The block's kind (B34) is the recorded `kind` when `block open` stored one, else `blockKind`
|
|
95
|
+
* of its owned files.
|
|
96
|
+
* With no run record the kind is null: `reviewTicket` takes the ticket file's kind.
|
|
97
|
+
* @returns {Promise<{base: string | null, level: string, owned: string[], acceptance?: unknown, kind: string | null}>}
|
|
93
98
|
*/
|
|
94
99
|
export async function blockEntryFor(runId, block) {
|
|
95
100
|
let record;
|
|
96
101
|
try {
|
|
97
102
|
record = await readRun(runId);
|
|
98
103
|
} catch (err) {
|
|
99
|
-
if (/** @type {any} */ (err)?.code === 'no-run') return { base: null, level: 'L2', owned: [], acceptance: undefined };
|
|
104
|
+
if (/** @type {any} */ (err)?.code === 'no-run') return { base: null, level: 'L2', owned: [], acceptance: undefined, kind: null };
|
|
100
105
|
throw new Error('run-record-unreadable');
|
|
101
106
|
}
|
|
102
107
|
if (!record || typeof record !== 'object' || !record.blocks || typeof record.blocks !== 'object') throw new Error('run-record-unreadable');
|
|
@@ -105,7 +110,7 @@ export async function blockEntryFor(runId, block) {
|
|
|
105
110
|
const sha = entry?.base_sha;
|
|
106
111
|
if (typeof sha !== 'string' || sha.length === 0) throw new Error('block-base-missing');
|
|
107
112
|
const owned = Array.isArray(entry.owned_files) ? entry.owned_files.filter((f) => typeof f === 'string') : [];
|
|
108
|
-
return { base: sha, level: typeof entry.level === 'string' && /^L[0-3]$/.test(entry.level) ? entry.level : 'L2', owned, acceptance: entry.acceptance };
|
|
113
|
+
return { base: sha, level: typeof entry.level === 'string' && /^L[0-3]$/.test(entry.level) ? entry.level : 'L2', owned, acceptance: entry.acceptance, kind: blockKind({ owned, declared: entry.kind }) };
|
|
109
114
|
}
|
|
110
115
|
|
|
111
116
|
/** The acceptance line written when the block's clauses cannot be read. */
|
|
@@ -189,60 +194,137 @@ export async function reviewTicket(ticket, ctx) {
|
|
|
189
194
|
} catch (err) {
|
|
190
195
|
return { status: 'unavailable', reason: err instanceof Error ? err.message : 'run-record-unreadable', approved: false, engine: 'adaptive', sessions: [] };
|
|
191
196
|
}
|
|
197
|
+
if (entry.kind === null) entry = { ...entry, kind: blockKind({ owned: [ticket.file] }) };
|
|
192
198
|
try {
|
|
193
199
|
if (!ctx.key) {
|
|
194
200
|
return await reviewFile(
|
|
195
|
-
{ repoRoot: ctx.repoRoot, file: ticket.file, base: entry.base, cfg: ctx.cfg, workDir, factsExcerpt: acceptanceExcerpt(entry.acceptance, ctx.cfg) },
|
|
201
|
+
{ repoRoot: ctx.repoRoot, file: ticket.file, base: entry.base, cfg: ctx.cfg, kind: entry.kind, workDir, factsExcerpt: acceptanceExcerpt(entry.acceptance, ctx.cfg) },
|
|
196
202
|
{ spawn: ctx.spawn, ...(ctx.writeRow ? { writeRow: ctx.writeRow } : {}) },
|
|
197
203
|
);
|
|
198
204
|
}
|
|
199
|
-
return await fixLoopRound(ticket, ctx, { base: entry.base, level: entry.level, owned: entry.owned, key: ctx.key, workDir, factsExcerpt: acceptanceExcerpt(entry.acceptance, ctx.cfg) });
|
|
205
|
+
return await fixLoopRound(ticket, ctx, { base: entry.base, level: entry.level, owned: entry.owned, blockKindOf: entry.kind, key: ctx.key, workDir, factsExcerpt: acceptanceExcerpt(entry.acceptance, ctx.cfg) });
|
|
200
206
|
} finally {
|
|
201
207
|
rmSync(workDir, { recursive: true, force: true });
|
|
202
208
|
}
|
|
203
209
|
}
|
|
204
210
|
|
|
211
|
+
/**
|
|
212
|
+
* The key of a block's lock (B42): the block's budget row and its L3 rung are taken under it.
|
|
213
|
+
* It carries the run, so two runs in one process never share a lock.
|
|
214
|
+
* @param {string} runId @param {string} block @returns {string}
|
|
215
|
+
*/
|
|
216
|
+
export const blockLockKey = (runId, block) => `block:${runId}\0${block}`;
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Whether the block's one L3 rung is taken: a verified anchor says so (`blockRungUsed`), OR another
|
|
220
|
+
* file of the block has a saved state whose round decided `next: patch` — that round took the rung,
|
|
221
|
+
* but its anchor records `l3_rung_used` only once the patch_check ticket saves. A sibling state that
|
|
222
|
+
* does not load is counted as taken (fail closed; its own ticket refuses it).
|
|
223
|
+
* @param {import('./review-state.mjs').Where} where
|
|
224
|
+
* @returns {{status: 'ok' | 'tampered', used: boolean}}
|
|
225
|
+
*/
|
|
226
|
+
export function rungTaken(where) {
|
|
227
|
+
const rung = blockRungUsed(where);
|
|
228
|
+
if (rung.status === 'tampered' || rung.used) return rung;
|
|
229
|
+
const anchors = blockAnchors(where) ?? [];
|
|
230
|
+
const siblings = new Set(anchors.map((a) => a.file).filter((f) => typeof f === 'string' && f !== where.file));
|
|
231
|
+
for (const file of siblings) {
|
|
232
|
+
let other;
|
|
233
|
+
try {
|
|
234
|
+
other = loadState({ ...where, file });
|
|
235
|
+
} catch {
|
|
236
|
+
return { status: 'ok', used: true };
|
|
237
|
+
}
|
|
238
|
+
if (other.status !== 'ok' || other.state?.next?.action === 'patch') return { status: 'ok', used: true };
|
|
239
|
+
}
|
|
240
|
+
return rung;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Whether this round could take the block's L3 rung (so it must run under the block lock): never
|
|
245
|
+
* when the rung is taken or the round is the patch_check (the rung is already this file's); never
|
|
246
|
+
* for a file's first full round while the cap and the per-level ladder are both above 1 (round 1
|
|
247
|
+
* cannot stall, reach the cap or exhaust the ladder); else conservatively yes.
|
|
248
|
+
* @param {import('../review/fixloop.mjs').FileState} state @param {'full' | 'recheck' | 'patch_check'} kind
|
|
249
|
+
* @param {Record<string, any>} cfg @param {boolean} rungUsed
|
|
250
|
+
* @returns {boolean}
|
|
251
|
+
*/
|
|
252
|
+
export function mayTakeRung(state, kind, cfg, rungUsed) {
|
|
253
|
+
if (rungUsed || kind === 'patch_check') return false;
|
|
254
|
+
const int = (/** @type {unknown} */ v, /** @type {number} */ d) => (Number.isInteger(v) && /** @type {number} */ (v) >= 0 ? /** @type {number} */ (v) : d);
|
|
255
|
+
const maxRounds = int(cfg?.review?.max_rounds_per_file, 4);
|
|
256
|
+
const perLevel = int(cfg?.escalation?.review_rounds_per_level, 2);
|
|
257
|
+
if (kind === 'full' && state.round === 0 && state.rounds_at_level === 0 && maxRounds > 1 && perLevel > 1) return false;
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
|
|
205
261
|
/**
|
|
206
262
|
* One fix-loop round for the ticket (see the module doc).
|
|
263
|
+
*
|
|
264
|
+
* Concurrency (B42): the worker runs several tickets at once but never two for the same
|
|
265
|
+
* (block, file) (its file lock, `loop.mjs`), so this file's state `seq` is read and saved by one
|
|
266
|
+
* ticket at a time. What the files of a BLOCK share is taken under the block lock
|
|
267
|
+
* (`blockLockKey`): the budget row's check-then-write (written once per block) and the L3 rung —
|
|
268
|
+
* the rung read and, for a round that could take the rung (`mayTakeRung`), the whole round and its
|
|
269
|
+
* state save, so two files of one block never both take it. A round that cannot take the rung (a
|
|
270
|
+
* first full round, or any round once the rung is gone) runs outside the block lock, in parallel
|
|
271
|
+
* with the block's other files.
|
|
207
272
|
* @param {import('./queue.mjs').Ticket} ticket @param {ReviewContext} ctx
|
|
208
|
-
* @param {{base: string | null, level: string, owned: string[], key: Buffer, workDir: string, factsExcerpt: string}} opts
|
|
273
|
+
* @param {{base: string | null, level: string, owned: string[], blockKindOf: string, key: Buffer, workDir: string, factsExcerpt: string}} opts
|
|
209
274
|
* @returns {Promise<ReviewOutcome>}
|
|
210
275
|
*/
|
|
211
|
-
async function fixLoopRound(ticket, ctx, { base, level, owned, key, workDir, factsExcerpt }) {
|
|
276
|
+
async function fixLoopRound(ticket, ctx, { base, level, owned, blockKindOf, key, workDir, factsExcerpt }) {
|
|
212
277
|
if (!ctx.readRows || !ctx.writeRow) return { status: 'unavailable', reason: 'no-ledger', approved: false, engine: 'adaptive', sessions: [] };
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
278
|
+
const readRows = ctx.readRows;
|
|
279
|
+
/** @type {{outcome: ReviewOutcome} | {round: () => Promise<ReviewOutcome>}} */
|
|
280
|
+
const prepared = await keyedLock(blockLockKey(ctx.runId, ticket.block), async () => {
|
|
281
|
+
let rows;
|
|
282
|
+
try {
|
|
283
|
+
rows = (await readRows()).filter((r) => r?.run === ctx.runId);
|
|
284
|
+
} catch (err) {
|
|
285
|
+
return { outcome: /** @type {ReviewOutcome} */ ({ status: 'unavailable', reason: failureReason(err), approved: false, engine: 'adaptive', sessions: [] }) };
|
|
286
|
+
}
|
|
287
|
+
// Check-then-write under the block lock: no other ticket of this block reads the rows between
|
|
288
|
+
// this check and the budget row's write, so the row is written once per block.
|
|
289
|
+
if (!rows.some((r) => r?.event === 'review.budget' && r.block === ticket.block)) {
|
|
290
|
+
await recordBlockBudget(ticket, ctx, { base, owned });
|
|
291
|
+
}
|
|
292
|
+
const where = { runRootDir: ctx.runRootDir, runId: ctx.runId, block: ticket.block, file: ticket.file, key, rows };
|
|
293
|
+
const loaded = loadState(where);
|
|
294
|
+
const rung = rungTaken(where);
|
|
295
|
+
if (loaded.status === 'tampered' || rung.status === 'tampered') return { outcome: /** @type {ReviewOutcome} */ ({ status: 'refused', reason: 'review-state-tampered', approved: false, engine: 'adaptive', sessions: [] }) };
|
|
296
|
+
const rungUsed = rung.used;
|
|
297
|
+
let state = loaded.state;
|
|
298
|
+
if (state === null || state.status === 'complete') state = newFileState({ file: ticket.file, level, l3RungUsed: rungUsed });
|
|
299
|
+
state.l3_rung_used = state.l3_rung_used || rungUsed;
|
|
233
300
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
301
|
+
if (state.status === 'stopped') {
|
|
302
|
+
return { outcome: /** @type {ReviewOutcome} */ ({ ...loopFields(state, null), status: 'stopped', stopped: state.next?.reason ?? 'stopped', approved: false, engine: 'adaptive', sessions: [], findings: state.open }) };
|
|
303
|
+
}
|
|
237
304
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
305
|
+
/** @type {'full' | 'recheck' | 'patch_check'} */
|
|
306
|
+
let kind = state.round === 0 ? 'full' : 'recheck';
|
|
307
|
+
if (state.next?.action === 'retry') kind = state.pending_kind ?? kind;
|
|
308
|
+
else if (state.next?.action === 'patch') {
|
|
309
|
+
kind = 'patch_check';
|
|
310
|
+
state.l3_rung_used = true; // the orchestrator ran the block's one L3 patch before this ticket
|
|
311
|
+
}
|
|
312
|
+
const fixed = state;
|
|
313
|
+
const round = () => playRound(ticket, ctx, { state: fixed, kind, where, seq: loaded.seq, base, blockKindOf, workDir, factsExcerpt });
|
|
314
|
+
// a round that could take the rung keeps the block lock until its state is saved
|
|
315
|
+
if (mayTakeRung(fixed, kind, ctx.cfg, rungUsed)) return { outcome: await round() };
|
|
316
|
+
return { round };
|
|
317
|
+
});
|
|
318
|
+
return 'outcome' in prepared ? prepared.outcome : prepared.round();
|
|
319
|
+
}
|
|
245
320
|
|
|
321
|
+
/**
|
|
322
|
+
* Run the prepared round and save the file's state: the ticket's outcome.
|
|
323
|
+
* @param {import('./queue.mjs').Ticket} ticket @param {ReviewContext} ctx
|
|
324
|
+
* @param {{state: import('../review/fixloop.mjs').FileState, kind: 'full' | 'recheck' | 'patch_check', where: import('./review-state.mjs').Where, seq: number, base: string | null, blockKindOf: string, workDir: string, factsExcerpt: string}} opts
|
|
325
|
+
* @returns {Promise<ReviewOutcome>}
|
|
326
|
+
*/
|
|
327
|
+
async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKindOf, workDir, factsExcerpt }) {
|
|
246
328
|
/** @type {Record<string, any> | null} */
|
|
247
329
|
let engineOutcome = null;
|
|
248
330
|
let approvalWritten = false;
|
|
@@ -276,7 +358,7 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, key, workDir, fac
|
|
|
276
358
|
factsExcerpt,
|
|
277
359
|
...(jev ? { jev } : {}),
|
|
278
360
|
review: async () => {
|
|
279
|
-
engineOutcome = await reviewFile({ repoRoot: ctx.repoRoot, file: ticket.file, base, cfg: ctx.cfg, workDir, factsExcerpt }, { spawn: ctx.spawn, writeRow });
|
|
361
|
+
engineOutcome = await reviewFile({ repoRoot: ctx.repoRoot, file: ticket.file, base, cfg: ctx.cfg, kind: blockKindOf, workDir, factsExcerpt }, { spawn: ctx.spawn, writeRow });
|
|
280
362
|
return engineOutcome;
|
|
281
363
|
},
|
|
282
364
|
spawn: async (opts) => {
|
|
@@ -302,7 +384,7 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, key, workDir, fac
|
|
|
302
384
|
state.pending_kind = kind;
|
|
303
385
|
}
|
|
304
386
|
}
|
|
305
|
-
await saveState({ ...where, state, seq
|
|
387
|
+
await saveState({ ...where, state, seq, writeRow: /** @type {(row: Record<string, any>) => Promise<unknown>} */ (ctx.writeRow) });
|
|
306
388
|
|
|
307
389
|
const eng = /** @type {Record<string, any> | null} */ (engineOutcome);
|
|
308
390
|
const head = { ...loopFields(state, kind), engine: eng?.engine ?? 'adaptive', sessions: eng?.sessions ?? rechecks };
|