@codedology/code-forge 0.11.0 → 0.13.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 +80 -0
- package/README.md +30 -2
- package/docs/reference/blocks.json +359 -1
- package/docs/reference/config.md +7 -5
- package/package.json +2 -2
- package/schema/code-forge.schema.json +4 -2
- package/skill/SKILL.md +2 -0
- package/skill/references/code.md +19 -5
- package/skill/references/proof.md +8 -2
- package/skill/references/review.md +16 -7
- package/skill/references/rules-core.md +1 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +15 -3
- package/src/autopilot/actions.mjs +4 -5
- package/src/cli/block.mjs +41 -0
- package/src/cli/context.mjs +3 -2
- package/src/cli/gates.mjs +15 -6
- package/src/cli/review-file.mjs +114 -3
- package/src/cli/review.mjs +2 -2
- package/src/config/validate.mjs +12 -5
- package/src/context/pack.mjs +20 -13
- package/src/doctor/index.mjs +2 -1
- package/src/doctor/local.mjs +19 -0
- package/src/doctor/worker-probe.mjs +15 -2
- package/src/gates/expand.mjs +102 -9
- package/src/gates/run.mjs +6 -4
- package/src/install/wizard/flags.mjs +1 -1
- package/src/ledger/spend.mjs +3 -3
- package/src/proof/lock.mjs +27 -10
- package/src/proof/rule.mjs +9 -0
- package/src/proof/tiers.mjs +2 -14
- package/src/review/fixloop.mjs +11 -6
- package/src/review/gate-check.mjs +34 -9
- package/src/review/lenses/A.md +4 -0
- package/src/review/lenses/B.md +4 -0
- package/src/review/lenses/full.md +4 -0
- package/src/review/lenses/judge.md +4 -0
- package/src/review/lenses/quick.md +4 -0
- package/src/review/lenses/recheck.md +4 -0
- package/src/review/paired.mjs +1 -0
- package/src/review/reopen.mjs +18 -6
- package/src/review/schema-fallback.mjs +3 -3
- package/src/util/exec.mjs +2 -1
- package/src/util/file-locks.mjs +118 -0
- package/src/util/forbidden.mjs +29 -2
- package/src/util/gate-token.mjs +3 -0
- package/src/worker/engine.mjs +2 -2
- package/src/worker/loop.mjs +98 -33
- package/src/worker/queue.mjs +183 -33
- package/src/worker/watch.mjs +115 -0
package/src/proof/lock.mjs
CHANGED
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
* with `isolation: lock` through the B6 writer.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import {
|
|
19
|
+
import { changedScope, fileBaseline, hasScopeItem, testScope } from '../gates/expand.mjs';
|
|
20
|
+
import { readAllRows } from '../ledger/write.mjs';
|
|
20
21
|
import { ownedFileCount } from '../gates/owned-count.mjs';
|
|
21
22
|
import { runGates } from '../gates/run.mjs';
|
|
22
23
|
import { StateError } from '../state/paths.mjs';
|
|
@@ -139,23 +140,39 @@ export async function withGateSlot({ runId, blockId, fn, pid = process.pid }) {
|
|
|
139
140
|
* run record once the slot is taken). The full suite runs instead when the block owns more than
|
|
140
141
|
* `threshold` (`gates.full_suite_threshold_files`) files, or when no test file is known. The
|
|
141
142
|
* owned files are counted by `ownedFileCount` over the tracked and new (not ignored) files at
|
|
142
|
-
* `cwd`.
|
|
143
|
-
* and
|
|
144
|
-
* @param {{runId: string, blockId: string, gates: Parameters<typeof runGates>[0], cwd: string, timeoutMs?: number, threshold?: number}} opts
|
|
143
|
+
* `cwd`. Plain gates without --changed retain the old path. B99 records bounded owned hashes
|
|
144
|
+
* after green scoped runs; --changed uses the last green record and B97's stem pairs.
|
|
145
|
+
* @param {{runId: string, blockId: string, gates: Parameters<typeof runGates>[0], cwd: string, timeoutMs?: number, threshold?: number, changed?: boolean, slug?: string}} opts
|
|
145
146
|
*/
|
|
146
|
-
export async function runGatesGuarded({ runId, blockId, gates, cwd, timeoutMs, threshold }) {
|
|
147
|
+
export async function runGatesGuarded({ runId, blockId, gates, cwd, timeoutMs, threshold, changed = false, slug }) {
|
|
147
148
|
return withGateSlot({
|
|
148
149
|
runId,
|
|
149
150
|
blockId,
|
|
150
151
|
fn: async () => {
|
|
151
|
-
//
|
|
152
|
-
if (!
|
|
152
|
+
// Plain gates without --changed need no scope or baseline.
|
|
153
|
+
if (!hasScopeItem(gates) && !changed) {
|
|
154
|
+
const record = await readRun(runId);
|
|
155
|
+
const ran = await runGates(gates, { cwd, ...(timeoutMs ? { timeoutMs } : {}) });
|
|
156
|
+
return { ...ran, ledger_slug: slug ?? record.project };
|
|
157
|
+
}
|
|
153
158
|
// the slot checked the block is open; it may still be gone by the time it is read again
|
|
154
|
-
const
|
|
159
|
+
const record = await readRun(runId);
|
|
160
|
+
const block = record.blocks?.[blockId];
|
|
155
161
|
if (!block) throw new StateError('no-block', `block ${blockId} is not in run ${runId}`);
|
|
156
162
|
const ownedFiles = await ownedFileCount(cwd, block.owned_files, block.owned_kinds);
|
|
157
|
-
const
|
|
158
|
-
|
|
163
|
+
const fallback = testScope({ testFiles: block.test_files, owned: block.owned_files, ownedFiles, ...(threshold === undefined ? {} : { threshold }) });
|
|
164
|
+
const current = await fileBaseline(cwd, block.owned_files, block.owned_kinds);
|
|
165
|
+
let baseline;
|
|
166
|
+
if (changed) {
|
|
167
|
+
const rows = await readAllRows(slug ?? record.project);
|
|
168
|
+
baseline = rows.filter((row) => row.event === 'gate.done' && row.run === runId && row.block === blockId && row.all_ok === true && row.worktree === block.worktree?.path).at(-1)?.files;
|
|
169
|
+
}
|
|
170
|
+
const scope = changed ? changedScope({ baseline, current, fallback, owned: block.owned_files, kinds: block.owned_kinds, ...(threshold === undefined ? {} : { threshold }) }) : fallback;
|
|
171
|
+
const ran = await runGates(gates, { cwd, ...(timeoutMs ? { timeoutMs } : {}), scope });
|
|
172
|
+
// A command that changed an input did not measure the resulting bytes. Do not baseline it.
|
|
173
|
+
const after = ran.allOk && current ? await fileBaseline(cwd, block.owned_files, block.owned_kinds) : null;
|
|
174
|
+
const files = after && JSON.stringify(current?.files) === JSON.stringify(after.files) ? after.files : undefined;
|
|
175
|
+
return { ...ran, ledger_slug: slug ?? record.project, ...(files ? { files } : {}), ...(changed ? { changed_scope: scope } : {}) };
|
|
159
176
|
},
|
|
160
177
|
});
|
|
161
178
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** The built-in proof rule carried by every coder context pack. */
|
|
2
|
+
export const PROOF_RULE = [
|
|
3
|
+
"## proof rule (code-forge)",
|
|
4
|
+
"- Red→green: each new or changed test must FAIL on the old code first (red), then PASS on the new code (green). A red caused by an import, compile or crash error proves nothing; the red must be an assertion failure. `code-forge proof red-green` does this with the block's base code.",
|
|
5
|
+
"- No hand mutations. Do not break the code on purpose to test the tests. Red→green is the proof.",
|
|
6
|
+
"- One exact test per acceptance clause, plus one negative or boundary test where the clause can fail. Assertions say which element and how many, never only \"it exists\".",
|
|
7
|
+
"- Proof scales with risk: a light-tier file needs red→green per new test; a high-tier file must have it at the gate. Do not add tests, mutants, layers, options or abstractions that the acceptance does not ask for.",
|
|
8
|
+
"- A weak assertion is fixed by a stronger assertion, not by more tests or mutants.",
|
|
9
|
+
].join('\n');
|
package/src/proof/tiers.mjs
CHANGED
|
@@ -5,9 +5,8 @@
|
|
|
5
5
|
* high ⇐ risk ≥ 2, or the path matches `proof.tiers.high.paths`, or `security_sensitive`
|
|
6
6
|
* light ⇐ everything else (the review-depth band `1 ≤ risk < 2` does NOT raise the proof tier)
|
|
7
7
|
*
|
|
8
|
-
* The light tier
|
|
9
|
-
*
|
|
10
|
-
* `held` and the gate enforces red→green only until Ricardo answers. Pure: no I/O.
|
|
8
|
+
* The light tier needs red→green per new or changed test. The high tier must have red→green
|
|
9
|
+
* at the gate. Mutation testing was cut by the owner (Q16). Pure: no I/O.
|
|
11
10
|
*/
|
|
12
11
|
|
|
13
12
|
import { ownsFile } from '../state/registry.mjs';
|
|
@@ -50,14 +49,3 @@ export function blockTier(fileTiers) {
|
|
|
50
49
|
if (!Array.isArray(fileTiers)) throw new TypeError('blockTier: fileTiers must be an array');
|
|
51
50
|
return fileTiers.some((t) => t?.tier === 'high') ? 'high' : 'light';
|
|
52
51
|
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* What the gate requires for a tier. `mutants: 'held'` = the high tier's mutation proof is
|
|
56
|
-
* specified but not enforced until Q16 is answered (B10c).
|
|
57
|
-
* @param {Tier} tier
|
|
58
|
-
* @returns {{red_green: true, mutants: 'none' | 'held'}}
|
|
59
|
-
*/
|
|
60
|
-
export function proofRequirements(tier) {
|
|
61
|
-
if (!TIERS.includes(tier)) throw new TypeError(`proofRequirements: tier must be one of ${TIERS.join(', ')}`);
|
|
62
|
-
return { red_green: true, mutants: tier === 'high' ? 'held' : 'none' };
|
|
63
|
-
}
|
package/src/review/fixloop.mjs
CHANGED
|
@@ -126,6 +126,7 @@ import { afterSchemaMiss, logSecondLevelMiss, packetHash } from './schema-fallba
|
|
|
126
126
|
* @property {string | null} reviewed_content - the content the last counted round reviewed.
|
|
127
127
|
* @property {boolean} l3_rung_used - the block's L3 rung (rule 5 or 6) was used — once per block.
|
|
128
128
|
* @property {'open' | 'complete' | 'stopped'} status
|
|
129
|
+
* @property {string} [stop_content_hash] - bytes reviewed by a stop (absent in legacy states).
|
|
129
130
|
* @property {NextStep | null} next
|
|
130
131
|
* @property {{context_mode: string, context_lines: number} | null} [last_packet] - the last recheck round's context: one packet's, or over a Markdown recheck's section packets the summed lines and the one mode they share, else `mixed` (B54).
|
|
131
132
|
* @property {'full' | 'recheck' | 'patch_check' | null} [pending_kind] - the round to re-run after a `retry`.
|
|
@@ -620,11 +621,11 @@ function retry(state, kind, reason) {
|
|
|
620
621
|
* not written, the open set stays as it was, and the gate-relevant `review.cap` row goes through
|
|
621
622
|
* the must-write path — a failed write is a `retry`, never a silent stop.
|
|
622
623
|
* @param {FileState} state @param {LoopDeps} deps
|
|
623
|
-
* @param {'full' | 'recheck' | 'patch_check'} kind @param {string} file
|
|
624
|
+
* @param {'full' | 'recheck' | 'patch_check'} kind @param {string} file @param {string} contentHash
|
|
624
625
|
* @param {{status: string, tokensIn?: number, budget?: number, section?: string | null}} packet - the non-ok packet (a Markdown section names its heading).
|
|
625
626
|
* @returns {Promise<FileState>}
|
|
626
627
|
*/
|
|
627
|
-
async function stopBeforeRound(state, deps, kind, file, packet) {
|
|
628
|
+
async function stopBeforeRound(state, deps, kind, file, contentHash, packet) {
|
|
628
629
|
const reason = packet.status;
|
|
629
630
|
const size =
|
|
630
631
|
typeof packet.tokensIn === 'number' && typeof packet.budget === 'number'
|
|
@@ -633,6 +634,7 @@ async function stopBeforeRound(state, deps, kind, file, packet) {
|
|
|
633
634
|
const row = { event: 'review.cap', file, round: state.round, reason, open: state.open.map((f) => f.id), ...size };
|
|
634
635
|
if (!(await mustNote(deps, row))) return retry(state, kind, 'ledger_write_failed');
|
|
635
636
|
state.status = 'stopped';
|
|
637
|
+
state.stop_content_hash = contentHash;
|
|
636
638
|
state.next = { action: 'stop', reason, ...size };
|
|
637
639
|
state.pending_kind = null;
|
|
638
640
|
return state;
|
|
@@ -703,7 +705,7 @@ export async function runRound(state, deps, opts = {}) {
|
|
|
703
705
|
// a Markdown file whose packet is `split_required`, rechecked section by section (B54);
|
|
704
706
|
// any other status stops as it always did
|
|
705
707
|
const packed = whole.status === 'split_required' && isMarkdownFile(current.rel) ? packRecheck(diff, pending, whole.budget, moves) : null;
|
|
706
|
-
if (!packed || packed.status !== 'ok') return stopBeforeRound(state, deps, kind, current.rel, packed ?? whole);
|
|
708
|
+
if (!packed || packed.status !== 'ok') return stopBeforeRound(state, deps, kind, current.rel, current.hash, packed ?? whole);
|
|
707
709
|
parts = packed.sections.map((sec) => ({ index: sec.index, heading: sec.headings[0], diff: sec.diff, open: sec.open, keys: sec.keys }));
|
|
708
710
|
}
|
|
709
711
|
// EVERY packet is built and held to the budget BEFORE any session runs: a section over it
|
|
@@ -718,9 +720,9 @@ export async function runRound(state, deps, opts = {}) {
|
|
|
718
720
|
const moved = movedText(moves, diff.content, part.keys);
|
|
719
721
|
const packet = buildRecheckPacket({ diff: part.diff, open: part.open, cfg: deps.cfg, contextMode, rulesDigest: deps.rulesDigest, factsExcerpt: deps.factsExcerpt, moved, paired });
|
|
720
722
|
// a packet that fails to build stops with ITS status (and size, when it has one)
|
|
721
|
-
if (packet.status !== 'ok') return stopBeforeRound(state, deps, kind, current.rel, { ...packet, section: part.heading });
|
|
723
|
+
if (packet.status !== 'ok') return stopBeforeRound(state, deps, kind, current.rel, current.hash, { ...packet, section: part.heading });
|
|
722
724
|
const size = recheckTokens(part.diff, part.open, moved);
|
|
723
|
-
if (size > whole.budget) return stopBeforeRound(state, deps, kind, current.rel, { status: 'split_required', tokensIn: size, budget: whole.budget, section: part.heading });
|
|
725
|
+
if (size > whole.budget) return stopBeforeRound(state, deps, kind, current.rel, current.hash, { status: 'split_required', tokensIn: size, budget: whole.budget, section: part.heading });
|
|
724
726
|
built.push(packet);
|
|
725
727
|
}
|
|
726
728
|
/** @type {Set<Finding>} the pending findings a session that LISTED them resolved */
|
|
@@ -804,7 +806,10 @@ export async function runRound(state, deps, opts = {}) {
|
|
|
804
806
|
const after = new Set(draft.open.map((f) => f.id));
|
|
805
807
|
const closedIds = new Set(state.open.map((f) => f.id).filter((id) => !after.has(id))).size;
|
|
806
808
|
draft.next = decideNext(draft, openBefore, s, kind, closedIds);
|
|
807
|
-
if (draft.next.action === 'stop')
|
|
809
|
+
if (draft.next.action === 'stop') {
|
|
810
|
+
draft.status = 'stopped';
|
|
811
|
+
draft.stop_content_hash = current.hash;
|
|
812
|
+
}
|
|
808
813
|
}
|
|
809
814
|
const rows = [...lateRows, roundRow];
|
|
810
815
|
if (draft.status === 'stopped') rows.push({ event: 'review.cap', file: current.rel, round: draft.round, reason: draft.next.reason, open: draft.open.map((f) => f.id) });
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* session really reviewed these bytes; a repeated stop or an `unavailable` result never counts),
|
|
17
17
|
* the latest refusal row for the hash is signed, carries `content_hash` (a hash-less one does
|
|
18
18
|
* not qualify, though it still refuses) and names at least one finding, and every finding it
|
|
19
|
-
* names and every open id of the file's `review.cap` rows is cleared by a waiver the gate
|
|
19
|
+
* names and every open id of the file's unsuperseded `review.cap` rows is cleared by a waiver the gate
|
|
20
20
|
* accepts (the same rule as below: a critical only by `by: human`). Such a file is never
|
|
21
21
|
* approved; the close writes one signed `gate.review_waived_all` row for it ({@link reviewGateCheck});
|
|
22
22
|
* - `unsigned`: the file's approvals for that hash exist but none carries a valid MAC;
|
|
@@ -43,11 +43,12 @@
|
|
|
43
43
|
* - `review_cap`: a file stopped at `review_cap` has an open finding with no signed
|
|
44
44
|
* `review.waived {by: human}` row — unless a signed approval of the file's current hash comes
|
|
45
45
|
* AFTER the cap row (ledger order) and no later refusal row made it stale (the human fixed it
|
|
46
|
-
* by hand and it was reviewed)
|
|
46
|
+
* by hand and it was reviewed), or a verified human reopen after the cap is followed by a
|
|
47
|
+
* signed review round of the current bytes; a finding already refused as `not_approved` for that file is
|
|
47
48
|
* not repeated. B73: this detail, and an `unreviewed` detail for a file with a cap row, ends
|
|
48
49
|
* with the stop's ways out (`exits: block reopen, block waive, review-file --answers,
|
|
49
50
|
* re-decompose`, `stop-exits.mjs`);
|
|
50
|
-
* - `unproven`: a changed owned file of tier `high` (B20,
|
|
51
|
+
* - `unproven`: a changed owned file of tier `high` (B20, the owner 2026-09-25) has no covering
|
|
51
52
|
* red→green proof (`checkBlockProof`, below). Light-tier files need no proof row.
|
|
52
53
|
* B71 (answers): a finding is also cleared — wherever a waiver clears it, by the one rule set
|
|
53
54
|
* ({@link reviewRules}: `waiverOf`, so `reviewed_by_waiver` reads "waived or answered") — by a
|
|
@@ -137,7 +138,7 @@ export function checkBlockReviews({ block, runId, files, rows, key, transcript =
|
|
|
137
138
|
/** @type {Map<string, string>} */
|
|
138
139
|
const current = new Map();
|
|
139
140
|
for (const f of [...proofFiles, ...files]) current.set(f.file, f.content_hash);
|
|
140
|
-
const { mine, latestOf, signedOf, matches, waiverOf, waiverFor, waivedAllOf, recordedRefusalSeverity } = reviewRules({ block, runId, rows, key, resultFiles, current });
|
|
141
|
+
const { mine, latestOf, signedOf, matches, waiverOf, waiverFor, waivedAllOf, recordedRefusalSeverity, supersededCap } = reviewRules({ block, runId, rows, key, resultFiles, current });
|
|
141
142
|
/** @type {Refusal[]} */
|
|
142
143
|
const refusals = [];
|
|
143
144
|
const approvedFor = (/** @type {string} */ file, /** @type {string} */ hash) => mine.filter((r) => r.event === 'review.approved' && r.file === file && r.content_hash === hash);
|
|
@@ -216,6 +217,7 @@ export function checkBlockReviews({ block, runId, files, rows, key, transcript =
|
|
|
216
217
|
}
|
|
217
218
|
|
|
218
219
|
for (const cap of mine.filter((r) => r.event === 'review.cap')) {
|
|
220
|
+
if (supersededCap(cap)) continue;
|
|
219
221
|
const hash = hashOf.get(cap.file);
|
|
220
222
|
// honoured unless a signed approval of the current content comes AFTER the cap and is not stale
|
|
221
223
|
if (hash !== undefined) {
|
|
@@ -260,6 +262,16 @@ function reviewRules({ block, runId, rows, key, resultFiles = {}, current, verif
|
|
|
260
262
|
// namedOnHash}), which has no key and can only refuse; it then reads no autopilot waiver
|
|
261
263
|
const ok = verify ?? ((/** @type {Record<string, any>} */ r) => verified(r, key));
|
|
262
264
|
const mine = rows.filter((r) => r?.block === block && (runId === undefined || r.run === undefined || r.run === runId));
|
|
265
|
+
/** A human reopen followed by a real review of the current bytes clears older cap ids.
|
|
266
|
+
* @param {Record<string, any>} cap @returns {boolean}
|
|
267
|
+
*/
|
|
268
|
+
const supersededCap = (cap) => {
|
|
269
|
+
const hash = current.get(cap.file);
|
|
270
|
+
if (hash === undefined) return false;
|
|
271
|
+
const at = mine.indexOf(cap);
|
|
272
|
+
return mine.some((r, i) => i > at && r.event === 'review.reopened' && r.run === runId && r.file === cap.file && r.by === 'human' && ok(r)
|
|
273
|
+
&& mine.some((round, j) => j > i && round.event === 'review.round' && round.run === runId && round.file === cap.file && round.content_hash === hash && ok(round)));
|
|
274
|
+
};
|
|
263
275
|
/**
|
|
264
276
|
* B52: the index of the last signed approval and the latest refusal AFTER it for (file, hash);
|
|
265
277
|
* `denial` is null when that approval stands (or there is none — `unreviewed` / `unsigned` say
|
|
@@ -380,7 +392,7 @@ function reviewRules({ block, runId, rows, key, resultFiles = {}, current, verif
|
|
|
380
392
|
};
|
|
381
393
|
|
|
382
394
|
/**
|
|
383
|
-
* B79: the ids every signed `review.cap` row for (file, hash) names. A cap row records no
|
|
395
|
+
* B79: the ids every unsuperseded signed `review.cap` row for (file, hash) names. A cap row records no
|
|
384
396
|
* severity, so the rules read each of these ids at {@link recordedRefusalSeverity} — also when
|
|
385
397
|
* that cap row is the latest refusal (its own null never stands for them).
|
|
386
398
|
* @param {string} file @param {string} hash @returns {Set<string>}
|
|
@@ -390,6 +402,7 @@ function reviewRules({ block, runId, rows, key, resultFiles = {}, current, verif
|
|
|
390
402
|
const ids = new Set();
|
|
391
403
|
for (const cap of mine) {
|
|
392
404
|
if (cap.event !== 'review.cap' || cap.file !== file || cap.content_hash !== hash || !Array.isArray(cap.open) || !ok(cap)) continue;
|
|
405
|
+
if (supersededCap(cap)) continue;
|
|
393
406
|
for (const id of cap.open) if (typeof id === 'string' && id.length > 0) ids.add(id);
|
|
394
407
|
}
|
|
395
408
|
return ids;
|
|
@@ -400,7 +413,7 @@ function reviewRules({ block, runId, rows, key, resultFiles = {}, current, verif
|
|
|
400
413
|
* or null when no such review qualifies. All of: a signed `review.round` row for (file, hash) — a
|
|
401
414
|
* session reviewed these bytes; the latest refusal row for (file, hash) is signed, carries
|
|
402
415
|
* `content_hash: hash` (a hash-less one never qualifies) and names at least one finding. The ids
|
|
403
|
-
* are every finding it names and every open id of the file's `review.cap` rows. Severities: an id
|
|
416
|
+
* are every finding it names and every open id of the file's unsuperseded `review.cap` rows. Severities: an id
|
|
404
417
|
* a signed `review.cap` row at (file, hash) names ({@link capIdsAt}) — also when that cap row is
|
|
405
418
|
* the latest refusal — and any other open cap id use {@link recordedRefusalSeverity}, whose
|
|
406
419
|
* `unknown` (no signed refusal at `hash` records one) is the human's alone and never answered.
|
|
@@ -417,6 +430,7 @@ function reviewRules({ block, runId, rows, key, resultFiles = {}, current, verif
|
|
|
417
430
|
const named = new Map(latest.open.map((f) => [f.id, f.severity]));
|
|
418
431
|
for (const id of capIdsAt(file, hash)) named.set(id, recordedRefusalSeverity(file, hash, id));
|
|
419
432
|
for (const cap of mine.filter((r) => r.event === 'review.cap' && r.file === file)) {
|
|
433
|
+
if (supersededCap(cap)) continue;
|
|
420
434
|
if (!Array.isArray(cap.open) || !cap.open.every((/** @type {unknown} */ id) => typeof id === 'string' && id.length > 0)) return null;
|
|
421
435
|
for (const id of cap.open) if (!named.has(id)) named.set(id, recordedRefusalSeverity(file, hash, id));
|
|
422
436
|
}
|
|
@@ -477,7 +491,7 @@ function reviewRules({ block, runId, rows, key, resultFiles = {}, current, verif
|
|
|
477
491
|
return new Map([...named].filter(([id, severity]) => id !== PROOF_FINDING && answerableSeverity(severity) && waiverOf(file, id, severity, ticket).by === null));
|
|
478
492
|
};
|
|
479
493
|
|
|
480
|
-
return { mine, latestOf, signedOf, matches, waiverOf, waiverFor, waivedAllOf, openAt, answerRows, recordedRefusalSeverity };
|
|
494
|
+
return { mine, latestOf, signedOf, matches, waiverOf, waiverFor, waivedAllOf, openAt, answerRows, recordedRefusalSeverity, supersededCap };
|
|
481
495
|
}
|
|
482
496
|
|
|
483
497
|
/**
|
|
@@ -803,7 +817,7 @@ export function closeTier({ file, rows, key, highPaths }) {
|
|
|
803
817
|
}
|
|
804
818
|
|
|
805
819
|
/**
|
|
806
|
-
* B20 (
|
|
820
|
+
* B20 (the owner 2026-09-25): every changed owned file of tier `high` needs a covering red→green
|
|
807
821
|
* proof, checked PER FILE. A file is covered by a `proof` row of this run and block (`rows` must
|
|
808
822
|
* already be the block's rows) with `step: 'red-green'`, `proven: true`, `red_kind: 'assertion'`,
|
|
809
823
|
* whose MAC verifies and whose `covers` list (written by `proof red-green`: the sources the
|
|
@@ -1045,7 +1059,18 @@ export function reviewGateCheck(load, { onWaivedAll } = {}) {
|
|
|
1045
1059
|
onWaivedAll?.(result.waivedAll);
|
|
1046
1060
|
return { ok: true };
|
|
1047
1061
|
}
|
|
1048
|
-
|
|
1062
|
+
const reasons = [];
|
|
1063
|
+
const transcriptIds = new Set();
|
|
1064
|
+
for (const r of result.refusals) {
|
|
1065
|
+
const hit = r.code === 'rule_break' && /^transcript line \d+: (.+)$/.exec(r.detail);
|
|
1066
|
+
if (hit) {
|
|
1067
|
+
if (transcriptIds.has(hit[1])) continue;
|
|
1068
|
+
transcriptIds.add(hit[1]);
|
|
1069
|
+
if (transcriptIds.size <= 5) reasons.push(`rule_break ${r.detail}`);
|
|
1070
|
+
} else reasons.push(`${r.code}${r.file ? ` ${r.file}` : ''}${r.finding ? ` ${r.finding}` : ''}`);
|
|
1071
|
+
}
|
|
1072
|
+
if (transcriptIds.size > 5) reasons.push(`+${transcriptIds.size - 5} more rule_break`);
|
|
1073
|
+
return { ok: false, reason: reasons.join('; ') };
|
|
1049
1074
|
};
|
|
1050
1075
|
}
|
|
1051
1076
|
|
package/src/review/lenses/A.md
CHANGED
|
@@ -11,6 +11,10 @@ You work blind: you do not see the other reviewer's answer.
|
|
|
11
11
|
- State: ordering, concurrency, partial writes, values that can be stale.
|
|
12
12
|
- Arithmetic and units: rounding, overflow, bytes versus characters, time zones.
|
|
13
13
|
|
|
14
|
+
Never ask for hand mutations or for tests beyond the acceptance clauses.
|
|
15
|
+
The fix for a weak assertion is a stronger assertion.
|
|
16
|
+
Code or tests beyond what the change needs (unused options, extra layers, speculative generality) is at most a `nit` unless it hides a bug.
|
|
17
|
+
|
|
14
18
|
## Rules for every answer
|
|
15
19
|
|
|
16
20
|
- Judge only what is in the packet; if you need a file you cannot see, return it in `needs_file` instead of guessing.
|
package/src/review/lenses/B.md
CHANGED
|
@@ -12,6 +12,10 @@ You work blind: you do not see the other reviewer's answer.
|
|
|
12
12
|
that can escape a root (`..`, absolute, symlinks); missing authorization; unsafe defaults.
|
|
13
13
|
- Facts: a flag, path or API the change relies on that the facts excerpt contradicts.
|
|
14
14
|
|
|
15
|
+
Never ask for hand mutations or for tests beyond the acceptance clauses.
|
|
16
|
+
The fix for a weak assertion is a stronger assertion.
|
|
17
|
+
Code or tests beyond what the change needs (unused options, extra layers, speculative generality) is at most a `nit` unless it hides a bug.
|
|
18
|
+
|
|
15
19
|
## Rules for every answer
|
|
16
20
|
|
|
17
21
|
- Judge only what is in the packet; if you need a file you cannot see, return it in `needs_file` instead of guessing.
|
|
@@ -12,6 +12,10 @@ You are a code reviewer doing a FULL review of one changed file.
|
|
|
12
12
|
|
|
13
13
|
Report each real defect once, at the lines where it lives.
|
|
14
14
|
|
|
15
|
+
Never ask for hand mutations or for tests beyond the acceptance clauses.
|
|
16
|
+
The fix for a weak assertion is a stronger assertion.
|
|
17
|
+
Code or tests beyond what the change needs (unused options, extra layers, speculative generality) is at most a `nit` unless it hides a bug.
|
|
18
|
+
|
|
15
19
|
## Rules for every answer
|
|
16
20
|
|
|
17
21
|
- Judge only what is in the packet; if you need a file you cannot see, return it in `needs_file` instead of guessing.
|
|
@@ -13,6 +13,10 @@ their two answers, the list of hunk headers, and the diff only when the packet i
|
|
|
13
13
|
- Your `findings` list is final: nothing you drop is fixed, everything you keep is.
|
|
14
14
|
- `passed` is true only when you keep no `critical` and no `warning`.
|
|
15
15
|
|
|
16
|
+
Never ask for hand mutations or for tests beyond the acceptance clauses.
|
|
17
|
+
The fix for a weak assertion is a stronger assertion.
|
|
18
|
+
Code or tests beyond what the change needs (unused options, extra layers, speculative generality) is at most a `nit` unless it hides a bug.
|
|
19
|
+
|
|
16
20
|
## Rules for every answer
|
|
17
21
|
|
|
18
22
|
- Judge only what is in the packet; if you need a file you cannot see, return it in `needs_file` instead of guessing.
|
|
@@ -10,6 +10,10 @@ You are a code reviewer doing a QUICK pass on one small, low-risk change.
|
|
|
10
10
|
|
|
11
11
|
Skip style, naming and structure unless it hides a bug. Keep `findings` short.
|
|
12
12
|
|
|
13
|
+
Never ask for hand mutations or for tests beyond the acceptance clauses.
|
|
14
|
+
The fix for a weak assertion is a stronger assertion.
|
|
15
|
+
Code or tests beyond what the change needs (unused options, extra layers, speculative generality) is at most a `nit` unless it hides a bug.
|
|
16
|
+
|
|
13
17
|
## Rules for every answer
|
|
14
18
|
|
|
15
19
|
- Judge only what is in the packet; if you need a file you cannot see, return it in `needs_file` instead of guessing.
|
|
@@ -15,6 +15,10 @@ that changed since that round, each with a window of the current file around it.
|
|
|
15
15
|
- `passed` is true only when every open finding is resolved and you raised no new `critical`
|
|
16
16
|
or `warning`.
|
|
17
17
|
|
|
18
|
+
Never ask for hand mutations or for tests beyond the acceptance clauses.
|
|
19
|
+
The fix for a weak assertion is a stronger assertion.
|
|
20
|
+
Code or tests beyond what the change needs (unused options, extra layers, speculative generality) is at most a `nit` unless it hides a bug.
|
|
21
|
+
|
|
18
22
|
## Rules for every answer
|
|
19
23
|
|
|
20
24
|
- Judge only what is in the packet; if you need a file you cannot see, return it in `needs_file` instead of guessing.
|
package/src/review/paired.mjs
CHANGED
|
@@ -17,6 +17,7 @@ function identity(file) {
|
|
|
17
17
|
if (stem.endsWith('_test')) { test = true; stem = stem.slice(0, -5); }
|
|
18
18
|
} else if (ext === '.py') {
|
|
19
19
|
if (stem.startsWith('test_')) { test = true; stem = stem.slice(5); }
|
|
20
|
+
else if (stem.endsWith('_test')) { test = true; stem = stem.slice(0, -5); }
|
|
20
21
|
} else if (ext === '.swift') {
|
|
21
22
|
if (stem.endsWith('Tests')) { test = true; stem = stem.slice(0, -5); }
|
|
22
23
|
} else return null;
|
package/src/review/reopen.mjs
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* - `reopen-not-stopped` — no state, or a state that is open or complete;
|
|
19
19
|
* - `reopen-same-content` — a stopped file whose current content is the content its stop
|
|
20
20
|
* reviewed (no free re-roll: the human waives the findings instead), or a stop whose content
|
|
21
|
-
* is unknown;
|
|
21
|
+
* is unknown and the current hash already has a verified review round;
|
|
22
22
|
* - `reopen-already-recorded` — a verified human row already names the file's latest anchor seq
|
|
23
23
|
* for other content (run `review-file`, or the content changed since that reopen);
|
|
24
24
|
* - `reopen-write-failed` — the signed row was not written.
|
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
* which the worker sets on every stop (`src/worker/engine.mjs`) and which is signed through the
|
|
33
33
|
* anchor. A state from before B72 has none: then the latest verified `review.cap` row of THIS
|
|
34
34
|
* stop counts — written after the file's latest verified `review.reopened` row, with the state's
|
|
35
|
-
* round and stop reason — else the content is unknown
|
|
35
|
+
* round and stop reason — else the content is unknown. B114: unknown content can restart only
|
|
36
|
+
* when no verified review.round of this run/block/file names the current hash (from_hash: null).
|
|
36
37
|
*
|
|
37
38
|
* The worker (`fixLoopRound`) asks `reopenRowFor`: a verified human row of this run for the block
|
|
38
39
|
* and file whose `at_seq` is the file's latest anchor seq starts a new state (round 1, a FULL
|
|
@@ -102,6 +103,15 @@ export function stopContentHash(state, rows, { runId, block, file, key }) {
|
|
|
102
103
|
return null;
|
|
103
104
|
}
|
|
104
105
|
|
|
106
|
+
/**
|
|
107
|
+
* A legacy stop with unknown content can restart only on bytes with no verified round here.
|
|
108
|
+
* @param {ReadonlyArray<Record<string, any>>} rows
|
|
109
|
+
* @param {{runId: string, block: string, file: string, key: Buffer, contentHash: string}} at
|
|
110
|
+
*/
|
|
111
|
+
function hasReviewedHash(rows, { runId, block, file, key, contentHash }) {
|
|
112
|
+
return rows.some((r) => r?.event === 'review.round' && r.run === runId && r.block === block && r.file === file && r.content_hash === contentHash && verified(r, key));
|
|
113
|
+
}
|
|
114
|
+
|
|
105
115
|
/**
|
|
106
116
|
* The block checks every reopen runs first, before any config, key or ledger read: the run must
|
|
107
117
|
* know the block (`unknown_block`, the code `block waive` uses) and the block must be open.
|
|
@@ -138,7 +148,8 @@ function latestRowAt(rows, { runId, block, file, key, seq }) {
|
|
|
138
148
|
* verifies with the run key, of this run, block and file, `by: human`, and whose `at_seq` is the
|
|
139
149
|
* file's latest anchor seq counts (the latest such row). A `lost` state needs `from: lost`; a
|
|
140
150
|
* stopped state needs `from: stopped` AND current content that is neither the row's `from_hash`
|
|
141
|
-
* nor the content the stop reviewed (`stopContentHash
|
|
151
|
+
* nor the content the stop reviewed (`stopContentHash`). Unknown content requires a null
|
|
152
|
+
* from_hash, the row's to_hash equal to current content, and no verified round at that hash.
|
|
142
153
|
* @param {ReadonlyArray<Record<string, any>>} rows
|
|
143
154
|
* @param {{runId: string, block: string, file: string, key: Buffer, status: string, state: Record<string, any> | null, seq: number, contentHash: string}} at
|
|
144
155
|
* @returns {Record<string, any> | null}
|
|
@@ -149,7 +160,8 @@ export function reopenRowFor(rows, { runId, block, file, key, status, state, seq
|
|
|
149
160
|
if (status === 'lost') return row.from === 'lost' ? row : null;
|
|
150
161
|
if ((status === 'ok' || status === 'restored') && state?.status === 'stopped' && row.from === 'stopped') {
|
|
151
162
|
const stopHash = stopContentHash(state, rows, { runId, block, file, key });
|
|
152
|
-
if (
|
|
163
|
+
if (contentHash === stopHash || contentHash === row.from_hash) return null;
|
|
164
|
+
if (stopHash === null && (row.from_hash !== null || row.to_hash !== contentHash || hasReviewedHash(rows, { runId, block, file, key, contentHash }))) return null;
|
|
153
165
|
return row;
|
|
154
166
|
}
|
|
155
167
|
return null;
|
|
@@ -200,8 +212,8 @@ export async function reopenFile({ runId, id, file, reason, record, rows, key, w
|
|
|
200
212
|
from = 'stopped';
|
|
201
213
|
stopReason = typeof loaded.state.next?.reason === 'string' ? loaded.state.next.reason : 'stopped';
|
|
202
214
|
fromHash = stopContentHash(loaded.state, runRows, { runId, block: id, file, key });
|
|
203
|
-
if (fromHash === null) {
|
|
204
|
-
throw new StateError('reopen-same-content', `reopen-same-content: the content the stop of ${file} reviewed is unknown
|
|
215
|
+
if (fromHash === null && hasReviewedHash(runRows, { runId, block: id, file, key, contentHash: current })) {
|
|
216
|
+
throw new StateError('reopen-same-content', `reopen-same-content: the content the stop of ${file} reviewed is unknown and its current content has already been reviewed; change the file and run block reopen, or the owner can run block close --no-require-reviews; nothing was recorded`);
|
|
205
217
|
}
|
|
206
218
|
if (fromHash === current) {
|
|
207
219
|
throw new StateError('reopen-same-content', `reopen-same-content: ${file} still has the content its stop (${stopReason}) reviewed; change the file first, or waive its findings (block waive); nothing was recorded`);
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
* Concurrency: the worker never runs two tickets for one (block, file) at once (its file lock,
|
|
60
60
|
* `worker/loop.mjs`), and round 1, its retry, every recheck and the patch_check of a file are all
|
|
61
61
|
* tickets for that file, so one packet's history is read and written by one ticket at a time. The
|
|
62
|
-
* read-history → write-mark section here also runs under
|
|
62
|
+
* read-history → write-mark section here also runs under a cross-process lock per (packet hash,
|
|
63
63
|
* level, role), so even the same file in two blocks cannot take a packet's one try twice.
|
|
64
64
|
*
|
|
65
65
|
* No ledger writer (`writeRow` absent) ⇒ nothing is logged and nothing is tried. The helpers never
|
|
@@ -71,7 +71,7 @@ import { createHash } from 'node:crypto';
|
|
|
71
71
|
import { closedBookRefused, openBookCodexAllowed } from '../config/closed-book.mjs';
|
|
72
72
|
import { resolveLevel } from '../config/known-ids.mjs';
|
|
73
73
|
import { logWarning } from '../util/error-log.mjs';
|
|
74
|
-
import {
|
|
74
|
+
import { withLock } from '../util/file-locks.mjs';
|
|
75
75
|
import { schemaErrorsOf } from './validate-review.mjs';
|
|
76
76
|
|
|
77
77
|
/** Broken results (B91: schema misses and timeouts) of the level's own ladder on one packet before the second level is tried. */
|
|
@@ -291,7 +291,7 @@ export async function afterSchemaMiss({ res, session, packetText, cfg, allowed,
|
|
|
291
291
|
/** @type {{cfg: Record<string, any>, to: {level: string, provider: string, model: string}} | null} */
|
|
292
292
|
let go = null;
|
|
293
293
|
try {
|
|
294
|
-
go = await
|
|
294
|
+
go = await withLock(`schema-fallback\0${hash}\0${session.level}\0${session.role}`, async () => {
|
|
295
295
|
// the history is read BEFORE a schema miss is logged: failures = signed earlier misses + this
|
|
296
296
|
// one. A timeout's note is already in it (B91): it is the miss in hand, never added twice.
|
|
297
297
|
let seen = null;
|
package/src/util/exec.mjs
CHANGED
|
@@ -139,6 +139,7 @@ function untrackChild(pid) {
|
|
|
139
139
|
* @param {object} [opts]
|
|
140
140
|
* @param {string} [opts.cwd]
|
|
141
141
|
* @param {NodeJS.ProcessEnv} [opts.env]
|
|
142
|
+
* @param {BufferEncoding} [opts.stdoutEncoding] - stdout encoding (default UTF-8); base64 preserves binary git blobs.
|
|
142
143
|
* @param {number} [opts.timeoutMs] - Kill the child (and its process group) if it runs longer
|
|
143
144
|
* than this. Must be a finite number > 0 when given.
|
|
144
145
|
* @param {number[]} [opts.okExitCodes] - Exit codes treated as `result: "ok"` (default `[0]`).
|
|
@@ -212,7 +213,7 @@ export function exec(argv, opts = {}) {
|
|
|
212
213
|
child.kill(signal);
|
|
213
214
|
};
|
|
214
215
|
|
|
215
|
-
const stdoutText = () => Buffer.concat(stdoutChunks).toString('utf8');
|
|
216
|
+
const stdoutText = () => Buffer.concat(stdoutChunks).toString(opts.stdoutEncoding ?? 'utf8');
|
|
216
217
|
const stderrText = () => Buffer.concat(stderrChunks).toString('utf8');
|
|
217
218
|
|
|
218
219
|
/** @param {ExecResult} result */
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/** Cross-process locks, with FIFO serialization inside this process. */
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { closeSync, mkdirSync, openSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { processStartTime } from '../state/run.mjs';
|
|
7
|
+
import { keyedLock } from './locks.mjs';
|
|
8
|
+
|
|
9
|
+
/** @typedef {{probe?: typeof processStartTime, alive?: (pid: number) => boolean, afterRead?: () => Promise<unknown>, pollMs?: number}} LockOpts */
|
|
10
|
+
/** @typedef {{ok: true, release: () => void} | {ok: false, result: 'held'}} LockResult */
|
|
11
|
+
/** @type {{ok: false, result: 'held'}} */
|
|
12
|
+
const HELD = { ok: false, result: 'held' };
|
|
13
|
+
const locksDir = () => path.join(os.homedir(), '.code-forge', 'locks');
|
|
14
|
+
/** @param {string} key */
|
|
15
|
+
const lockPath = (key) => path.join(locksDir(), `${createHash('sha256').update(key).digest('hex')}.lock`);
|
|
16
|
+
/** @type {Promise<string | null> | undefined} */
|
|
17
|
+
let selfStart;
|
|
18
|
+
|
|
19
|
+
/** @param {number} pid */
|
|
20
|
+
function alive(pid) {
|
|
21
|
+
try { process.kill(pid, 0); return true; }
|
|
22
|
+
catch (err) { return err.code !== 'ESRCH'; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** @param {Buffer} bytes @returns {{key: string, pid: number, start: string} | null} */
|
|
26
|
+
function ownerOf(bytes) {
|
|
27
|
+
try {
|
|
28
|
+
const owner = JSON.parse(bytes.toString('utf8'));
|
|
29
|
+
return owner && typeof owner.key === 'string' && Number.isInteger(owner.pid) && owner.pid > 1 && typeof owner.start === 'string' ? owner : null;
|
|
30
|
+
} catch { return null; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Exclusive creation; release synchronously removes only this owner's bytes.
|
|
34
|
+
* @param {string} file @param {Buffer} bytes @returns {LockResult}
|
|
35
|
+
*/
|
|
36
|
+
function create(file, bytes) {
|
|
37
|
+
let fd;
|
|
38
|
+
try { fd = openSync(file, 'wx', 0o600); }
|
|
39
|
+
catch (err) { if (err.code === 'EEXIST') return HELD; throw err; }
|
|
40
|
+
try { writeFileSync(fd, bytes); }
|
|
41
|
+
finally { closeSync(fd); }
|
|
42
|
+
let released = false;
|
|
43
|
+
return { ok: true, release() {
|
|
44
|
+
if (released) return;
|
|
45
|
+
released = true;
|
|
46
|
+
try { if (readFileSync(file).equals(bytes)) unlinkSync(file); }
|
|
47
|
+
catch { /* missing, unreadable or replaced: leave it alone */ }
|
|
48
|
+
} };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Take a key or fail closed. A proven dead owner permits one guarded break attempt.
|
|
52
|
+
* @param {string} key @param {LockOpts} [opts] @returns {Promise<LockResult>}
|
|
53
|
+
*/
|
|
54
|
+
export async function tryLock(key, opts = {}) {
|
|
55
|
+
const probe = opts.probe ?? processStartTime;
|
|
56
|
+
const start = await (selfStart ??= processStartTime(process.pid));
|
|
57
|
+
if (typeof start !== 'string') throw new Error('cannot determine lock owner process start time');
|
|
58
|
+
const file = lockPath(key);
|
|
59
|
+
const bytes = Buffer.from(JSON.stringify({ key, pid: process.pid, start }));
|
|
60
|
+
mkdirSync(locksDir(), { recursive: true, mode: 0o700 });
|
|
61
|
+
const initial = create(file, bytes);
|
|
62
|
+
if (initial.ok) return initial;
|
|
63
|
+
let seen;
|
|
64
|
+
try { seen = readFileSync(file); }
|
|
65
|
+
catch { return HELD; }
|
|
66
|
+
await opts.afterRead?.();
|
|
67
|
+
const owner = ownerOf(seen);
|
|
68
|
+
if (owner === null || owner.key !== key) return HELD;
|
|
69
|
+
let dead = !(opts.alive ?? alive)(owner.pid);
|
|
70
|
+
if (!dead) {
|
|
71
|
+
const actual = await probe(owner.pid);
|
|
72
|
+
dead = actual !== null && actual !== owner.start;
|
|
73
|
+
}
|
|
74
|
+
if (!dead) return HELD;
|
|
75
|
+
|
|
76
|
+
// Only unchanged dead-owner bytes may be removed under the per-key break mutex.
|
|
77
|
+
const mutex = `${file}.break`;
|
|
78
|
+
let guard;
|
|
79
|
+
try { guard = openSync(mutex, 'wx', 0o600); }
|
|
80
|
+
catch { return HELD; }
|
|
81
|
+
try {
|
|
82
|
+
writeFileSync(guard, bytes);
|
|
83
|
+
try { if (!readFileSync(file).equals(seen)) return HELD; }
|
|
84
|
+
catch { return HELD; }
|
|
85
|
+
unlinkSync(file);
|
|
86
|
+
return create(file, bytes);
|
|
87
|
+
} finally {
|
|
88
|
+
closeSync(guard);
|
|
89
|
+
unlinkSync(mutex);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** @template T @param {string} key @param {() => T | Promise<T>} fn @param {LockOpts} [opts] @returns {Promise<T>} */
|
|
94
|
+
export function withLock(key, fn, opts = {}) {
|
|
95
|
+
return keyedLock(key, async () => {
|
|
96
|
+
let lock = await tryLock(key, opts);
|
|
97
|
+
while (!lock.ok) {
|
|
98
|
+
await new Promise((resolve) => setTimeout(resolve, opts.pollMs ?? 25));
|
|
99
|
+
lock = await tryLock(key, opts);
|
|
100
|
+
}
|
|
101
|
+
try { return await fn(); }
|
|
102
|
+
finally { lock.release(); }
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** @returns {string[]} paths of unreadable/malformed locks or dead-owner break mutexes. */
|
|
107
|
+
export function listBadLocks() {
|
|
108
|
+
let names;
|
|
109
|
+
try { names = readdirSync(locksDir()); }
|
|
110
|
+
catch (err) { if (err.code === 'ENOENT') return []; throw err; }
|
|
111
|
+
return names.filter((name) => name.endsWith('.lock') || name.endsWith('.lock.break')).sort().map((name) => path.join(locksDir(), name)).filter((file) => {
|
|
112
|
+
try {
|
|
113
|
+
const owner = ownerOf(readFileSync(file));
|
|
114
|
+
return owner === null || (file.endsWith('.lock.break') && !alive(owner.pid));
|
|
115
|
+
}
|
|
116
|
+
catch { return true; }
|
|
117
|
+
});
|
|
118
|
+
}
|