@codedology/code-forge 0.11.0 → 0.12.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 +39 -0
- package/README.md +14 -2
- package/docs/reference/blocks.json +158 -1
- package/docs/reference/config.md +6 -5
- package/package.json +2 -2
- package/schema/code-forge.schema.json +3 -2
- package/skill/SKILL.md +2 -0
- package/skill/references/code.md +11 -4
- package/skill/references/proof.md +8 -2
- package/skill/references/review.md +6 -4
- package/skill/references/rules-core.md +1 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +15 -3
- package/src/cli/block.mjs +14 -0
- package/src/cli/gates.mjs +15 -6
- package/src/cli/review-file.mjs +101 -3
- package/src/config/validate.mjs +12 -5
- package/src/context/pack.mjs +7 -4
- package/src/gates/expand.mjs +102 -9
- package/src/gates/run.mjs +6 -4
- package/src/install/wizard/flags.mjs +1 -1
- package/src/proof/lock.mjs +23 -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 +2 -2
- 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/util/exec.mjs +2 -1
- package/src/util/gate-token.mjs +3 -0
- package/src/worker/loop.mjs +16 -1
- package/src/worker/queue.mjs +60 -2
- package/src/worker/watch.mjs +113 -0
|
@@ -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) });
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
* not repeated. B73: this detail, and an `unreviewed` detail for a file with a cap row, ends
|
|
48
48
|
* with the stop's ways out (`exits: block reopen, block waive, review-file --answers,
|
|
49
49
|
* re-decompose`, `stop-exits.mjs`);
|
|
50
|
-
* - `unproven`: a changed owned file of tier `high` (B20,
|
|
50
|
+
* - `unproven`: a changed owned file of tier `high` (B20, the owner 2026-09-25) has no covering
|
|
51
51
|
* red→green proof (`checkBlockProof`, below). Light-tier files need no proof row.
|
|
52
52
|
* B71 (answers): a finding is also cleared — wherever a waiver clears it, by the one rule set
|
|
53
53
|
* ({@link reviewRules}: `waiverOf`, so `reviewed_by_waiver` reads "waived or answered") — by a
|
|
@@ -803,7 +803,7 @@ export function closeTier({ file, rows, key, highPaths }) {
|
|
|
803
803
|
}
|
|
804
804
|
|
|
805
805
|
/**
|
|
806
|
-
* B20 (
|
|
806
|
+
* B20 (the owner 2026-09-25): every changed owned file of tier `high` needs a covering red→green
|
|
807
807
|
* proof, checked PER FILE. A file is covered by a `proof` row of this run and block (`rows` must
|
|
808
808
|
* already be the block's rows) with `step: 'red-green'`, `proven: true`, `red_kind: 'assertion'`,
|
|
809
809
|
* whose MAC verifies and whose `covers` list (written by `proof red-green`: the sources the
|
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`);
|
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 */
|
package/src/util/gate-token.mjs
CHANGED
package/src/worker/loop.mjs
CHANGED
|
@@ -57,6 +57,7 @@ import { countLines, RouteError, routeTicket } from '../review/route.mjs';
|
|
|
57
57
|
import { canonicalPath, contentRootFor, reviewTicket } from './engine.mjs';
|
|
58
58
|
import { announceWorker, beatWorker, HEARTBEAT_MS, liveWorker, pendingTickets, readTicket, retractWorker, writeResult } from './queue.mjs';
|
|
59
59
|
import { DELETED_HASH, hashBytes, readRegularFileNoFollow, repoRootOf, WorkerError } from './ticket.mjs';
|
|
60
|
+
import { watchTick } from './watch.mjs';
|
|
60
61
|
|
|
61
62
|
/** Name of the key the worker resolves for System 1 (`keys.jev` / `system1.key` reference). */
|
|
62
63
|
export const JEV_KEY_NAME = 'jev';
|
|
@@ -234,6 +235,8 @@ export function recordedVerdict(result) {
|
|
|
234
235
|
* @property {typeof spawnSession} [spawn]
|
|
235
236
|
* @property {typeof readRun} [readRun] - the run-record read behind the per-ticket config (a test seam).
|
|
236
237
|
* @property {typeof readTicket} [readTicket] - the ticket read (a test seam).
|
|
238
|
+
* @property {typeof import('../util/exec.mjs').exec} [watchExec] - watch git exec seam.
|
|
239
|
+
* @property {Parameters<typeof watchTick>[0]['hooks']} [watchHooks] - deterministic watch races.
|
|
237
240
|
* @property {import('../keys/store.mjs').KeyStore} [store] - default: B2's production store.
|
|
238
241
|
* @property {any} [opRead]
|
|
239
242
|
* @property {NodeJS.ProcessEnv} [env]
|
|
@@ -281,6 +284,9 @@ export async function createWorker(opts, deps = {}) {
|
|
|
281
284
|
|
|
282
285
|
/** @param {Record<string, any>} row */
|
|
283
286
|
const ledger = (row) => writeRow(signRow({ run: runId, ...row }, key));
|
|
287
|
+
// Shared across direct ticks and the scheduler, so running tickets are never superseded.
|
|
288
|
+
const activeTickets = new Set();
|
|
289
|
+
const tickWatch = () => watchTick({ repoRoot, runId, cfg, readRun: loadRun, writeRow: ledger, running: activeTickets, exec: deps.watchExec, hooks: deps.watchHooks });
|
|
284
290
|
|
|
285
291
|
/**
|
|
286
292
|
* The config ticket `id` is reviewed with (B35): its snapshot from the run record, re-read per
|
|
@@ -617,6 +623,13 @@ export async function createWorker(opts, deps = {}) {
|
|
|
617
623
|
}
|
|
618
624
|
}
|
|
619
625
|
if (!stopping) {
|
|
626
|
+
try {
|
|
627
|
+
await tickWatch();
|
|
628
|
+
} catch (thrown) {
|
|
629
|
+
const text = `watch tick failed (${errorKind(thrown)})`;
|
|
630
|
+
writeSafe(warnStream, `worker: WARN ${text}\n`);
|
|
631
|
+
try { await logWarning({ warning: 'watch_tick_failed', message: text }); } catch { /* a log failure never stops dispatch */ }
|
|
632
|
+
}
|
|
620
633
|
for (const id of pendingTickets(repoRoot)) {
|
|
621
634
|
if (poisoned.has(id) || started.has(id)) continue;
|
|
622
635
|
if (limit === null) limit = await poolSize();
|
|
@@ -624,6 +637,7 @@ export async function createWorker(opts, deps = {}) {
|
|
|
624
637
|
const fileKey = fileKeyOf(id);
|
|
625
638
|
if (fileKey !== null && busy.has(fileKey)) continue; // waits without a slot
|
|
626
639
|
started.add(id);
|
|
640
|
+
activeTickets.add(id);
|
|
627
641
|
if (fileKey !== null) busy.add(fileKey);
|
|
628
642
|
const slot = runOne(id)
|
|
629
643
|
.then((n) => {
|
|
@@ -632,6 +646,7 @@ export async function createWorker(opts, deps = {}) {
|
|
|
632
646
|
.finally(() => {
|
|
633
647
|
// the slot is freed on every exit path (`runOne` never rejects)
|
|
634
648
|
running.delete(id);
|
|
649
|
+
activeTickets.delete(id);
|
|
635
650
|
fileKeys.delete(id);
|
|
636
651
|
if (fileKey !== null) busy.delete(fileKey);
|
|
637
652
|
});
|
|
@@ -737,7 +752,7 @@ export async function createWorker(opts, deps = {}) {
|
|
|
737
752
|
retractWorker(repoRoot, process.pid);
|
|
738
753
|
}
|
|
739
754
|
|
|
740
|
-
return { processTicket, drain, run, once, stop, release, jevKeyResolved: jevKey !== null, sessionEnv: childEnv };
|
|
755
|
+
return { processTicket, drain, run, once, stop, release, watchTick: tickWatch, jevKeyResolved: jevKey !== null, sessionEnv: childEnv };
|
|
741
756
|
}
|
|
742
757
|
|
|
743
758
|
/**
|
package/src/worker/queue.mjs
CHANGED
|
@@ -102,17 +102,56 @@ export function resolveTicket({ repoRoot, run, block, file, tree = null }) {
|
|
|
102
102
|
* @throws {WorkerError} `bad-path` for a relative `tree` or one that cannot be resolved.
|
|
103
103
|
*/
|
|
104
104
|
export function enqueue({ repoRoot, run, block, file, tree = null, now = new Date() }) {
|
|
105
|
-
const {
|
|
105
|
+
const { content_hash } = resolveTicket({ repoRoot, run, block, file, tree });
|
|
106
|
+
return enqueueHash({ repoRoot, run, block, file, tree, content_hash, now });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Queue proven git-object content through the same identity as a manual review.
|
|
110
|
+
* @param {{repoRoot: string, run: string, block: string, file: string, content_hash: string, tree?: string | null, now?: Date}} opts
|
|
111
|
+
* @returns {{ticket: string, status: 'queued' | 'done', file: string, content_hash: string, tree?: string}}
|
|
112
|
+
*/
|
|
113
|
+
export function enqueueHash({ repoRoot, run, block, file, content_hash, tree = null, now = new Date() }) {
|
|
114
|
+
const rel = assertRowPath(file);
|
|
115
|
+
const root = tree === null ? null : realTree(tree);
|
|
116
|
+
const ticket = ticketId({ run, block, file: rel, content_hash, tree: root });
|
|
106
117
|
const target = path.join(queueDir(repoRoot), `${ticket}.json`);
|
|
107
118
|
const named = root === null ? {} : { tree: root };
|
|
108
119
|
if (!existsSync(target)) {
|
|
109
120
|
/** @type {Ticket} */
|
|
110
121
|
const body = { ticket, run, block, file: rel, content_hash, enqueued_at: now.toISOString(), ...named };
|
|
111
|
-
|
|
122
|
+
// Publish a complete immutable ticket without replacing a concurrent coder's ticket.
|
|
123
|
+
mkdirSync(path.dirname(target), { recursive: true });
|
|
124
|
+
const tmp = `${target}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
125
|
+
try {
|
|
126
|
+
writeFileSync(tmp, `${JSON.stringify(body)}\n`, { mode: 0o644 });
|
|
127
|
+
try { linkSync(tmp, target); } catch (err) { if (err.code !== 'EEXIST') throw err; }
|
|
128
|
+
} finally { rmSync(tmp, { force: true }); }
|
|
112
129
|
}
|
|
130
|
+
// Returning content is current again. Claim only a superseded marker's exact bytes;
|
|
131
|
+
// a real result that replaced it wins the comparison and is restored unchanged.
|
|
132
|
+
let seen;
|
|
133
|
+
let done;
|
|
134
|
+
try {
|
|
135
|
+
seen = readFileSync(path.join(queueDir(repoRoot), `${ticket}.done`));
|
|
136
|
+
done = JSON.parse(seen.toString('utf8'));
|
|
137
|
+
} catch { /* missing or unreadable markers remain untouched */ }
|
|
138
|
+
if (seen && done?.status === 'refused' && done.reason === 'superseded' && !done.result) dropDoneIf(repoRoot, ticket, seen);
|
|
113
139
|
return { ticket, status: isDone(repoRoot, ticket) ? 'done' : 'queued', file: rel, content_hash, ...named };
|
|
114
140
|
}
|
|
115
141
|
|
|
142
|
+
/** Finish only an unstarted, pending review. A superseded marker has no review result.
|
|
143
|
+
* @param {string} repoRoot @param {string} ticket
|
|
144
|
+
* @param {{write?: (file: string, text: string) => void, link?: (from: string, to: string) => void}} [io] - publication seams for tests.
|
|
145
|
+
*/
|
|
146
|
+
export function supersedeTicket(repoRoot, ticket, io = {}) {
|
|
147
|
+
const marker = path.join(queueDir(repoRoot), `${assertTicketId(ticket)}.done`);
|
|
148
|
+
const tmp = `${marker}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
149
|
+
try {
|
|
150
|
+
(io.write ?? ((file, text) => writeFileSync(file, text, { mode: 0o644 })))(tmp, `${JSON.stringify({ ticket, status: 'refused', reason: 'superseded' })}\n`);
|
|
151
|
+
try { (io.link ?? linkSync)(tmp, marker); } catch (err) { if (err.code !== 'EEXIST') throw err; }
|
|
152
|
+
} finally { rmSync(tmp, { force: true }); }
|
|
153
|
+
}
|
|
154
|
+
|
|
116
155
|
/** B71: the most finding ids one answers ticket may name. */
|
|
117
156
|
export const MAX_ANSWERS = 10;
|
|
118
157
|
|
|
@@ -369,6 +408,25 @@ export function pendingTickets(repoRoot) {
|
|
|
369
408
|
return pending.map((id) => ({ id, at: at(id) })).sort((a, b) => (a.at === b.at ? (a.id < b.id ? -1 : 1) : a.at < b.at ? -1 : 1)).map((e) => e.id);
|
|
370
409
|
}
|
|
371
410
|
|
|
411
|
+
/**
|
|
412
|
+
* Valid review tickets for one block and content tree, oldest first. Answers tickets cannot
|
|
413
|
+
* stand in for a file's own review. Unreadable tickets are refused, never silently omitted.
|
|
414
|
+
* @param {{repoRoot: string, run: string, block: string, tree?: string | null}} opts
|
|
415
|
+
* @returns {Ticket[]}
|
|
416
|
+
*/
|
|
417
|
+
export function blockTickets({ repoRoot, run, block, tree = null }) {
|
|
418
|
+
let names;
|
|
419
|
+
try {
|
|
420
|
+
names = readdirSync(queueDir(repoRoot));
|
|
421
|
+
} catch (err) {
|
|
422
|
+
if (err.code === 'ENOENT') return [];
|
|
423
|
+
throw new WorkerError('queue-unreadable', 'the review queue cannot be read');
|
|
424
|
+
}
|
|
425
|
+
const tickets = names.filter((n) => /^[0-9a-f]{24}\.json$/.test(n)).map((n) => readTicket(repoRoot, n.slice(0, 24)));
|
|
426
|
+
return tickets.filter((t) => !t.kind && t.run === run && t.block === block && (t.tree ?? null) === tree)
|
|
427
|
+
.sort((a, b) => a.enqueued_at.localeCompare(b.enqueued_at) || a.ticket.localeCompare(b.ticket));
|
|
428
|
+
}
|
|
429
|
+
|
|
372
430
|
/**
|
|
373
431
|
* Read and re-validate a ticket (coder-writable): its id must match its content.
|
|
374
432
|
* @param {string} repoRoot @param {string} id
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/** Opt-in commit watch. Read immutable git objects, never the coder's working bytes. */
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { reviewFileLoop } from '../config/validate.mjs';
|
|
6
|
+
import { snapshotFor } from '../state/config-snapshot.mjs';
|
|
7
|
+
import { readRun } from '../state/run.mjs';
|
|
8
|
+
import { ownsAt } from '../review/gate-check.mjs';
|
|
9
|
+
import { exec } from '../util/exec.mjs';
|
|
10
|
+
import { blockTickets, enqueueHash, isDone, queueDir, supersedeTicket } from './queue.mjs';
|
|
11
|
+
import { assertRowPath, DELETED_HASH, gitChildEnv, hashBytes } from './ticket.mjs';
|
|
12
|
+
|
|
13
|
+
const SHA = /^[0-9a-f]{40,64}$/;
|
|
14
|
+
const BLOCK = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* One scheduler tick, also exposed directly for deterministic race tests.
|
|
18
|
+
* The boot config gates ALL watch work; absent/wait/overlap never reads the run or spawns git.
|
|
19
|
+
* @param {{repoRoot: string, runId: string, cfg: Record<string, any>, readRun?: typeof readRun,
|
|
20
|
+
* writeRow: (row: Record<string, any>) => Promise<unknown>, exec?: typeof exec,
|
|
21
|
+
* running?: ReadonlySet<string>, hooks?: {afterHead?: (head: string) => Promise<unknown>, beforeQueue?: () => Promise<unknown>}}} opts
|
|
22
|
+
*/
|
|
23
|
+
export async function watchTick({ repoRoot, runId, cfg, readRun: loadRun = readRun, writeRow, exec: execute = exec, running = new Set(), hooks = {} }) {
|
|
24
|
+
if (reviewFileLoop(cfg) !== 'watch') return;
|
|
25
|
+
const record = await loadRun(runId);
|
|
26
|
+
if (record.status !== 'active' || reviewFileLoop(snapshotFor(record, '')?.config ?? cfg) !== 'watch') return;
|
|
27
|
+
const env = gitChildEnv();
|
|
28
|
+
for (const [block, entry] of Object.entries(record.blocks ?? {})) {
|
|
29
|
+
if (entry.status !== 'open' || !BLOCK.test(block)) continue;
|
|
30
|
+
const stateFile = path.join(queueDir(repoRoot), 'watch', `${block}.json`);
|
|
31
|
+
let tree = null;
|
|
32
|
+
let root = repoRoot;
|
|
33
|
+
let state = {};
|
|
34
|
+
try { state = JSON.parse(readFileSync(stateFile, 'utf8')) ?? {}; } catch { /* restart at base */ }
|
|
35
|
+
const base = entry.base_sha;
|
|
36
|
+
/** @param {Record<string, any>} next */
|
|
37
|
+
const save = (next) => {
|
|
38
|
+
mkdirSync(path.dirname(stateFile), { recursive: true });
|
|
39
|
+
const tmp = `${stateFile}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
40
|
+
try {
|
|
41
|
+
writeFileSync(tmp, `${JSON.stringify(next)}\n`, { mode: 0o600 });
|
|
42
|
+
renameSync(tmp, stateFile);
|
|
43
|
+
state = next;
|
|
44
|
+
} finally { rmSync(tmp, { force: true }); }
|
|
45
|
+
};
|
|
46
|
+
/** @param {string} reason */
|
|
47
|
+
const skip = async (reason) => {
|
|
48
|
+
const skipped = Array.isArray(state.skipped) ? state.skipped : [];
|
|
49
|
+
if (!skipped.includes(reason)) {
|
|
50
|
+
await writeRow({ event: 'watch.skipped', block, reason });
|
|
51
|
+
save({ ...state, skipped: [...skipped, reason] });
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
try {
|
|
55
|
+
if (entry.worktree) {
|
|
56
|
+
root = realpathSync(entry.worktree.path);
|
|
57
|
+
tree = root;
|
|
58
|
+
}
|
|
59
|
+
if (state.run !== runId || state.base !== base || state.tree !== tree) state = { run: runId, base, tree, skipped: [] };
|
|
60
|
+
/** @param {string[]} argv */
|
|
61
|
+
const git = (argv) => execute(['git', ...argv], { cwd: root, env, timeoutMs: 10000 });
|
|
62
|
+
const headResult = await git(['rev-parse', '--verify', 'HEAD^{commit}']);
|
|
63
|
+
const head = headResult.stdout.trim();
|
|
64
|
+
if (headResult.result !== 'ok' || !SHA.test(head)) { await skip('unreadable_head'); continue; }
|
|
65
|
+
const earlier = blockTickets({ repoRoot, run: runId, block, tree });
|
|
66
|
+
await hooks.afterHead?.(head);
|
|
67
|
+
if (typeof base !== 'string' || !SHA.test(base) || (await git(['merge-base', '--is-ancestor', base, head])).result !== 'ok') {
|
|
68
|
+
await skip('not_descendant'); continue;
|
|
69
|
+
}
|
|
70
|
+
const last = typeof state.sha === 'string' && SHA.test(state.sha) ? state.sha : base;
|
|
71
|
+
if (last !== base && (await git(['merge-base', '--is-ancestor', last, head])).result !== 'ok') {
|
|
72
|
+
await skip('rewound');
|
|
73
|
+
const { sha: _sha, ...restart } = state;
|
|
74
|
+
save(restart);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
// A healthy tick ends the skip streak: no new commit here, or a queued commit below. An
|
|
78
|
+
// unreadable commit keeps the streak, so it writes one watch.skipped row, not one per tick.
|
|
79
|
+
if (last === head) { if (state.skipped?.length) save({ ...state, skipped: [] }); continue; }
|
|
80
|
+
const diff = await git(['diff', '--name-only', '-z', '--no-renames', last, head, '--']);
|
|
81
|
+
if (diff.result !== 'ok') { await skip('unreadable_head'); continue; }
|
|
82
|
+
const owns = ownsAt(tree ? '' : path.relative(repoRoot, record.workspace).split(path.sep).join('/'));
|
|
83
|
+
const files = diff.stdout.split('\0').filter(Boolean);
|
|
84
|
+
const owned = files.filter((file) => owns(entry.owned_files, file, entry.owned_kinds));
|
|
85
|
+
if (owned.length !== files.length) await skip('unowned_path');
|
|
86
|
+
// Stage every hash before publishing anything: an unreadable object queues nothing.
|
|
87
|
+
const hashes = [];
|
|
88
|
+
let unreadable = false;
|
|
89
|
+
for (const raw of owned) {
|
|
90
|
+
const file = assertRowPath(raw);
|
|
91
|
+
const listed = await git(['ls-tree', '-z', head, '--', file]);
|
|
92
|
+
if (listed.result !== 'ok') { unreadable = true; break; }
|
|
93
|
+
if (!listed.stdout) { hashes.push({ file, content_hash: DELETED_HASH }); continue; }
|
|
94
|
+
if (!/^100(?:644|755) blob /.test(listed.stdout)) { unreadable = true; break; }
|
|
95
|
+
const shown = await execute(['git', 'show', `${head}:${file}`], { cwd: root, env, timeoutMs: 10000, stdoutEncoding: 'base64' });
|
|
96
|
+
if (shown.result !== 'ok') { unreadable = true; break; }
|
|
97
|
+
hashes.push({ file, content_hash: hashBytes(Buffer.from(shown.stdout, 'base64')) });
|
|
98
|
+
}
|
|
99
|
+
if (unreadable) { await skip('unreadable_head'); continue; }
|
|
100
|
+
await hooks.beforeQueue?.();
|
|
101
|
+
for (const { file, content_hash } of hashes) {
|
|
102
|
+
const current = enqueueHash({ repoRoot, run: runId, block, file, content_hash, tree });
|
|
103
|
+
for (const old of earlier) {
|
|
104
|
+
if (old.file === file && old.ticket !== current.ticket && !running.has(old.ticket) && !isDone(repoRoot, old.ticket)) supersedeTicket(repoRoot, old.ticket);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
save({ ...state, run: runId, base, tree, sha: head, skipped: [] });
|
|
108
|
+
} catch {
|
|
109
|
+
// A bad path, missing tree or failed git read is a skip, never a block failure.
|
|
110
|
+
await skip('unreadable_head');
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|