@codedology/code-forge 0.14.1 → 0.15.1
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 +29 -0
- package/README.md +1 -1
- package/docs/reference/blocks.json +495 -1
- package/docs/reference/config.md +7 -0
- package/package.json +1 -1
- package/schema/code-forge.schema.json +22 -0
- package/scripts/first-use.mjs +11 -5
- package/skill/SKILL.md +4 -0
- package/skill/references/autopilot.md +2 -0
- package/skill/references/code.md +28 -2
- package/skill/references/review.md +10 -2
- package/skill/references/security.md +2 -0
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +3 -1
- package/src/autopilot/scopes.mjs +2 -1
- package/src/cli/block.mjs +271 -50
- package/src/cli/gates.mjs +36 -6
- package/src/cli/ledger.mjs +21 -2
- package/src/cli/report.mjs +14 -0
- package/src/cli/review-file.mjs +56 -16
- package/src/cli/run.mjs +44 -7
- package/src/cli/status.mjs +7 -3
- package/src/config/validate.mjs +13 -1
- package/src/gates/acceptance.mjs +7 -3
- package/src/gates/expand.mjs +25 -2
- package/src/gates/run.mjs +38 -8
- package/src/ledger/report.mjs +29 -7
- package/src/review/accept-hit.mjs +22 -4
- package/src/review/engine.mjs +30 -10
- package/src/review/finding.schema.json +2 -1
- package/src/review/fixloop.mjs +79 -14
- package/src/review/gate-check.mjs +33 -7
- package/src/review/lenses/recheck.md +3 -0
- package/src/review/packet.mjs +26 -0
- package/src/state/block.mjs +196 -22
- package/src/state/run.mjs +35 -8
- package/src/util/forbidden.mjs +230 -6
- package/src/util/reaper.mjs +3 -3
- package/src/worker/engine.mjs +74 -6
- package/src/worker/loop.mjs +19 -10
- package/src/worker/queue.mjs +38 -8
package/src/util/forbidden.mjs
CHANGED
|
@@ -282,6 +282,7 @@ export const FORBIDDEN = Object.freeze(
|
|
|
282
282
|
* - `--no-require-reviews` — the human's way out of `block close`'s per-file review check;
|
|
283
283
|
* - `--remove-worktree` (B62a) — `block close` removing the block's worktree, the orchestrator's
|
|
284
284
|
* alone. `block close` itself is not on this list, so the flag is listed on its own.
|
|
285
|
+
* - `code-forge block human-pass` (B137) — passing a human device check is human-only.
|
|
285
286
|
* @type {ReadonlyArray<ForbiddenEntry>}
|
|
286
287
|
*/
|
|
287
288
|
export const CODER_ONLY_FORBIDDEN = Object.freeze(
|
|
@@ -346,6 +347,25 @@ export const CODER_ONLY_FORBIDDEN = Object.freeze(
|
|
|
346
347
|
prefixes: [['code-forge', 'block', 'ack-waivers'], ['forge', 'block', 'ack-waivers']],
|
|
347
348
|
description: 'Acknowledge agent waivers — human-only, confirmed in the human terminal (B124b)',
|
|
348
349
|
},
|
|
350
|
+
{
|
|
351
|
+
id: 'code-forge-block-human-pass-from-coder',
|
|
352
|
+
kind: 'anyPrefix',
|
|
353
|
+
prefixes: [['code-forge', 'block', 'human-pass'], ['forge', 'block', 'human-pass']],
|
|
354
|
+
description: 'Pass a human device check — human-only, confirmed in the human terminal (B137)',
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
id: 'code-forge-add-worker-from-coder',
|
|
358
|
+
kind: 'commandFlag',
|
|
359
|
+
command: ['code-forge', 'run', 'start'],
|
|
360
|
+
groups: [{ anyOf: ['--add-worker'] }],
|
|
361
|
+
description: 'Add a review worker to a run — the orchestrator only (B107)',
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
id: 'code-forge-block-amend-acceptance-from-coder',
|
|
365
|
+
kind: 'anyPrefix',
|
|
366
|
+
prefixes: [['code-forge', 'block', 'amend-acceptance'], ['forge', 'block', 'amend-acceptance']],
|
|
367
|
+
description: 'Correct a block acceptance list — human-only, confirmed in the human terminal (B143)',
|
|
368
|
+
},
|
|
349
369
|
].map(freezeEntry),
|
|
350
370
|
);
|
|
351
371
|
|
|
@@ -805,6 +825,194 @@ export function renderForCodex(list = FORBIDDEN) {
|
|
|
805
825
|
|
|
806
826
|
// ── Transcripts ──────────────────────────────────────────────────────────────
|
|
807
827
|
|
|
828
|
+
/**
|
|
829
|
+
* Mask inert, non-shell interpreter bodies for command/contains rules only. Walk shell
|
|
830
|
+
* words and queued heredocs once; unknown commands, malformed quotes and unfinished
|
|
831
|
+
* heredocs stay fail closed. Decode at most one shell -c wrapper; deeper wrappers
|
|
832
|
+
* stay whole. This deliberately does not evaluate shell expansions.
|
|
833
|
+
* @param {string} command
|
|
834
|
+
* @param {boolean} [unwrapShell]
|
|
835
|
+
* @returns {string}
|
|
836
|
+
*/
|
|
837
|
+
function transcriptCommandText(command, unwrapShell = true) {
|
|
838
|
+
if (unwrapShell) {
|
|
839
|
+
const wrapper = /^\s*(?:[^\s'"\\;|&()<>`$]+\/)?(?:sh|bash|zsh|dash|ksh)(?:[ \t]+-[li]+)*[ \t]+-[li]*c[ \t]+(['"])/.exec(command);
|
|
840
|
+
if (wrapper) {
|
|
841
|
+
let quote = wrapper[1];
|
|
842
|
+
const parts = [];
|
|
843
|
+
let i = wrapper[0].length;
|
|
844
|
+
while (i < command.length) {
|
|
845
|
+
const char = command[i++];
|
|
846
|
+
if (char === quote) {
|
|
847
|
+
// A shell word can splice opposite-quoted text into its enclosing
|
|
848
|
+
// quotes, as in "script"'literal'"rest". Require the enclosing
|
|
849
|
+
// quote again at the end; uncertain suffixes still scan whole.
|
|
850
|
+
if (command[i] === (quote === '"' ? "'" : '"')) {
|
|
851
|
+
quote = command[i++];
|
|
852
|
+
continue;
|
|
853
|
+
}
|
|
854
|
+
if (quote !== wrapper[1] || command.slice(i).trim() !== '') return command;
|
|
855
|
+
const prefix = command.slice(0, wrapper[0].length - 1);
|
|
856
|
+
return prefix + transcriptCommandText(parts.join(''), false);
|
|
857
|
+
}
|
|
858
|
+
// Within double quotes, other backslashes are literal; single quotes
|
|
859
|
+
// preserve every character. A backslash-newline pair disappears.
|
|
860
|
+
if (quote === '"' && char === '\\' && /["\\$`\n]/.test(command[i] ?? '')) {
|
|
861
|
+
const escaped = command[i++];
|
|
862
|
+
if (escaped !== '\n') parts.push(escaped);
|
|
863
|
+
} else {
|
|
864
|
+
// Keep the source segment's quote context until after this check:
|
|
865
|
+
// the parent shell expands these even inside an inner quoted heredoc.
|
|
866
|
+
// Single-quoted splices and the escaped characters above are literal.
|
|
867
|
+
// Unquoted splices already fail closed at the closing-quote check.
|
|
868
|
+
if (quote === '"' && (char === '`' || (char === '$' && command[i] === '('))) return command;
|
|
869
|
+
parts.push(char);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
return command;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
const spawnWord = /\b(?:exec|execSync|execFile|execFileSync|spawn|spawnSync|fork|system|popen|subprocess|child_process|Popen)\b/;
|
|
876
|
+
const interpreter = /^(node|python|python3|ruby|perl)(?:\d+(?:\.\d+)*)?$/;
|
|
877
|
+
/** @param {string} body */
|
|
878
|
+
const hasShellSubstitution = (body) => {
|
|
879
|
+
for (let i = 0; i < body.length; i += 1) {
|
|
880
|
+
// Skip escaped characters in pairs so even backslash runs still expand.
|
|
881
|
+
if (body[i] === '\\') { i += 1; continue; }
|
|
882
|
+
if (body[i] === '`' || (body[i] === '$' && body[i + 1] === '(')) return true;
|
|
883
|
+
}
|
|
884
|
+
return false;
|
|
885
|
+
};
|
|
886
|
+
/** @param {string} body @param {string} language @param {boolean} shellExpands */
|
|
887
|
+
const hasSpawn = (body, language, shellExpands) => spawnWord.test(body)
|
|
888
|
+
|| (shellExpands && hasShellSubstitution(body))
|
|
889
|
+
|| ((language === 'ruby' || language === 'perl') && /`|\$\(|%x/.test(body))
|
|
890
|
+
|| (language === 'perl' && /\bqx\b/.test(body));
|
|
891
|
+
const evalFlags = new Set(['-e', '-c', '--eval', '-p', '--print']);
|
|
892
|
+
/** @type {{start: number, end: number}[]} */
|
|
893
|
+
const inert = [];
|
|
894
|
+
/** @type {{delimiter: string, tabs: boolean, language: string, shellExpands: boolean}[]} */
|
|
895
|
+
let heredocs = [];
|
|
896
|
+
let state = 'command';
|
|
897
|
+
let language = '';
|
|
898
|
+
let evalNext = false;
|
|
899
|
+
let envValue = false;
|
|
900
|
+
let redirect = '';
|
|
901
|
+
let i = 0;
|
|
902
|
+
while (i < command.length) {
|
|
903
|
+
const char = command[i];
|
|
904
|
+
if (char === '\n') {
|
|
905
|
+
i += 1;
|
|
906
|
+
for (const doc of heredocs) {
|
|
907
|
+
const start = i;
|
|
908
|
+
let closed = false;
|
|
909
|
+
while (i < command.length) {
|
|
910
|
+
const newline = command.indexOf('\n', i);
|
|
911
|
+
const end = newline < 0 ? command.length : newline;
|
|
912
|
+
const line = command.slice(i, end);
|
|
913
|
+
if ((doc.tabs ? line.replace(/^\t+/, '') : line) === doc.delimiter) {
|
|
914
|
+
if (doc.language && !hasSpawn(command.slice(start, i), doc.language, doc.shellExpands)) inert.push({ start, end: i });
|
|
915
|
+
i = newline < 0 ? end : end + 1;
|
|
916
|
+
closed = true;
|
|
917
|
+
break;
|
|
918
|
+
}
|
|
919
|
+
i = newline < 0 ? end : end + 1;
|
|
920
|
+
}
|
|
921
|
+
if (!closed) return command;
|
|
922
|
+
}
|
|
923
|
+
heredocs = [];
|
|
924
|
+
state = 'command';
|
|
925
|
+
evalNext = false;
|
|
926
|
+
redirect = '';
|
|
927
|
+
continue;
|
|
928
|
+
}
|
|
929
|
+
if (/\s/.test(char)) { i += 1; continue; }
|
|
930
|
+
if (char === '#') {
|
|
931
|
+
const newline = command.indexOf('\n', i);
|
|
932
|
+
i = newline < 0 ? command.length : newline;
|
|
933
|
+
continue;
|
|
934
|
+
}
|
|
935
|
+
if (';|&()'.includes(char)) {
|
|
936
|
+
state = 'command';
|
|
937
|
+
evalNext = false;
|
|
938
|
+
i += 1;
|
|
939
|
+
continue;
|
|
940
|
+
}
|
|
941
|
+
if (char === '<' || char === '>') {
|
|
942
|
+
redirect = char;
|
|
943
|
+
i += 1;
|
|
944
|
+
if (command[i] === char) { redirect += char; i += 1; }
|
|
945
|
+
if (redirect === '<<' && command[i] === '-') { redirect += '-'; i += 1; }
|
|
946
|
+
// Here-strings and other uncertain shell syntax are never exempted.
|
|
947
|
+
if (command[i] === '<') return command;
|
|
948
|
+
continue;
|
|
949
|
+
}
|
|
950
|
+
const start = i;
|
|
951
|
+
const parts = [];
|
|
952
|
+
let quotes = 0;
|
|
953
|
+
let quote = '';
|
|
954
|
+
let escaped = false;
|
|
955
|
+
let unquoted = false;
|
|
956
|
+
while (i < command.length && !/[\s;|&()<>]/.test(command[i])) {
|
|
957
|
+
const next = command[i++];
|
|
958
|
+
if (next === "'" || next === '"') {
|
|
959
|
+
quotes += 1;
|
|
960
|
+
quote = next;
|
|
961
|
+
let closed = false;
|
|
962
|
+
while (i < command.length) {
|
|
963
|
+
const inside = command[i++];
|
|
964
|
+
if (inside === next) { closed = true; break; }
|
|
965
|
+
if (inside === '\\' && next === '"' && i < command.length) parts.push(command[i++]);
|
|
966
|
+
else parts.push(inside);
|
|
967
|
+
}
|
|
968
|
+
if (!closed) return command;
|
|
969
|
+
} else {
|
|
970
|
+
unquoted = true;
|
|
971
|
+
if (next === '\\') escaped = true;
|
|
972
|
+
parts.push(next === '\\' && i < command.length ? command[i++] : next);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
const word = parts.join('');
|
|
976
|
+
if (redirect) {
|
|
977
|
+
if (redirect.startsWith('<<')) heredocs.push({
|
|
978
|
+
delimiter: word, tabs: redirect === '<<-', language: state === 'interpreter' ? language : '',
|
|
979
|
+
shellExpands: quotes === 0 && !escaped,
|
|
980
|
+
});
|
|
981
|
+
redirect = '';
|
|
982
|
+
continue;
|
|
983
|
+
}
|
|
984
|
+
if (state === 'env') {
|
|
985
|
+
if (envValue) { envValue = false; continue; }
|
|
986
|
+
if (/^[A-Za-z_][A-Za-z_0-9]*=/.test(word) || ['-i', '--ignore-environment'].includes(word)) continue;
|
|
987
|
+
if (['-u', '--unset', '-C', '--chdir'].includes(word)) { envValue = true; continue; }
|
|
988
|
+
if (word === '--') { state = 'command'; continue; }
|
|
989
|
+
if (word.startsWith('-')) { state = 'other'; continue; }
|
|
990
|
+
state = 'command';
|
|
991
|
+
}
|
|
992
|
+
if (state === 'command') {
|
|
993
|
+
if (/^[A-Za-z_][A-Za-z_0-9]*=/.test(word)) continue;
|
|
994
|
+
const name = word.slice(word.lastIndexOf('/') + 1);
|
|
995
|
+
language = interpreter.exec(name)?.[1] ?? '';
|
|
996
|
+
state = name === 'env' ? 'env' : language ? 'interpreter' : 'other';
|
|
997
|
+
} else if (state === 'interpreter') {
|
|
998
|
+
if (evalNext) {
|
|
999
|
+
if (quotes === 1 && !unquoted && !hasSpawn(command.slice(start, i), language, quote === '"')) inert.push({ start, end: i });
|
|
1000
|
+
evalNext = false;
|
|
1001
|
+
} else if (evalFlags.has(word)) evalNext = true;
|
|
1002
|
+
else if (!word.startsWith('-') || word === '--') state = 'other';
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
if (heredocs.length > 0) return command;
|
|
1006
|
+
const chunks = [];
|
|
1007
|
+
let end = 0;
|
|
1008
|
+
for (const span of inert) {
|
|
1009
|
+
chunks.push(command.slice(end, span.start), ' ');
|
|
1010
|
+
end = span.end;
|
|
1011
|
+
}
|
|
1012
|
+
chunks.push(command.slice(end));
|
|
1013
|
+
return chunks.join('');
|
|
1014
|
+
}
|
|
1015
|
+
|
|
808
1016
|
/**
|
|
809
1017
|
* Scan captured coder output (subprocess stdout, Solo `search_output` text, a harness log) for
|
|
810
1018
|
* any entry of `list`, line by line: each line is split into shell-ish tokens (whitespace,
|
|
@@ -815,7 +1023,18 @@ export function renderForCodex(list = FORBIDDEN) {
|
|
|
815
1023
|
* Codex `exec --json` logs (every non-empty line an object with a string `type`, first event
|
|
816
1024
|
* `thread.started`) scan command_execution commands and file_change paths as writes. Other
|
|
817
1025
|
* items scan whole, except agent_message and reasoning; a command_execution's aggregated_output
|
|
818
|
-
* is never scanned; non-item events are skipped.
|
|
1026
|
+
* is never scanned; non-item events are skipped. For string commands, node/python/ruby/perl
|
|
1027
|
+
* heredocs and quoted -e/-c/--eval/-p/--print bodies without a process-spawn word are omitted
|
|
1028
|
+
* from command and contains rules only; path rules still scan the whole command. Backticks and
|
|
1029
|
+
* $( count when unescaped and the shell expands the body (unquoted heredoc delimiter or double-quoted inline
|
|
1030
|
+
* script), or for Ruby/Perl; %x counts for Ruby/Perl, and qx for Perl. Quoted or backslash-escaped
|
|
1031
|
+
* heredoc delimiters and single-quoted inline scripts prevent shell expansion. One sh/bash/zsh/
|
|
1032
|
+
* dash/ksh -c wrapper (including -lc/-ic and -l -c) with a single quoted script (including
|
|
1033
|
+
* opposite-quoted splices) is decoded first; its prefix remains scanned. Unescaped parent-shell
|
|
1034
|
+
* substitutions in double-quoted wrapper segments keep the entire command scanned; single-quoted
|
|
1035
|
+
* splices are literal at that layer. Unquoted splices, nested wrappers,
|
|
1036
|
+
* malformed wrappers, shell/unknown heredocs and
|
|
1037
|
+
* unterminated bodies scan whole. Any other log uses the plain-text grep above, unchanged.
|
|
819
1038
|
*
|
|
820
1039
|
* Linear in the line length: `contains` and `path` entries are evaluated ONCE per line over its
|
|
821
1040
|
* tokens (a `write` path entry hits when a write command starts before a token naming the path);
|
|
@@ -850,13 +1069,17 @@ export function scanTranscript(text, list = FORBIDDEN) {
|
|
|
850
1069
|
const hits = [];
|
|
851
1070
|
lines
|
|
852
1071
|
.forEach((lineText, index) => {
|
|
1072
|
+
let commandText;
|
|
853
1073
|
if (events !== null) {
|
|
854
1074
|
const event = events[index];
|
|
855
1075
|
if (!event?.type.startsWith('item.') || !event.item) return;
|
|
856
1076
|
const item = event.item;
|
|
857
1077
|
if (item.type === 'agent_message' || item.type === 'reasoning') return;
|
|
858
1078
|
// a command that is not a string is scanned with the rest of its item (fail closed), its output left out
|
|
859
|
-
if (item.type === 'command_execution')
|
|
1079
|
+
if (item.type === 'command_execution') {
|
|
1080
|
+
lineText = typeof item.command === 'string' ? item.command : JSON.stringify({ ...item, aggregated_output: undefined });
|
|
1081
|
+
if (typeof item.command === 'string') commandText = transcriptCommandText(lineText);
|
|
1082
|
+
}
|
|
860
1083
|
else if (item.type === 'file_change') {
|
|
861
1084
|
lineText = (Array.isArray(item.changes) ? item.changes : [])
|
|
862
1085
|
.filter((change) => typeof change?.path === 'string')
|
|
@@ -864,20 +1087,21 @@ export function scanTranscript(text, list = FORBIDDEN) {
|
|
|
864
1087
|
} else lineText = JSON.stringify(item);
|
|
865
1088
|
}
|
|
866
1089
|
const tokens = lineText.split(/(>>?)|[\s'"`;|&(),{}[\]]+/).filter((t) => typeof t === 'string' && t.length > 0);
|
|
1090
|
+
const commandTokens = commandText === undefined ? tokens : commandText.split(/(>>?)|[\s'"`;|&(),{}[\]]+/).filter((t) => typeof t === 'string' && t.length > 0);
|
|
867
1091
|
const ids = new Set();
|
|
868
1092
|
let firstWrite = -1;
|
|
869
1093
|
for (let i = 0; i < tokens.length && firstWrite < 0; i += 1) {
|
|
870
1094
|
if (writeStarts.has(commandName(tokens[i])) && isWriteCommand(tokens.slice(i, i + TRANSCRIPT_WINDOW))) firstWrite = i;
|
|
871
1095
|
}
|
|
872
1096
|
for (const entry of list) {
|
|
873
|
-
if (entry.kind === 'contains' && matchesEntry([],
|
|
1097
|
+
if (entry.kind === 'contains' && matchesEntry([], commandTokens, entry)) ids.add(entry.id);
|
|
874
1098
|
if (entry.kind !== 'path') continue;
|
|
875
1099
|
const named = tokens.findIndex((t, i) => (entry.access === 'readwrite' || (firstWrite >= 0 && i > firstWrite)) && namesPath(t, entry));
|
|
876
1100
|
if (named >= 0) ids.add(entry.id);
|
|
877
1101
|
}
|
|
878
|
-
for (let i = 0; i <
|
|
879
|
-
if (!firstWords.has(commandName(
|
|
880
|
-
const window =
|
|
1102
|
+
for (let i = 0; i < commandTokens.length; i += 1) {
|
|
1103
|
+
if (!firstWords.has(commandName(commandTokens[i])) && !commandTokens[i].includes('code-forge')) continue;
|
|
1104
|
+
const window = commandTokens.slice(i, i + TRANSCRIPT_WINDOW);
|
|
881
1105
|
for (const entry of commandEntries) {
|
|
882
1106
|
if (!ids.has(entry.id) && isForbidden(window, [entry])) ids.add(entry.id);
|
|
883
1107
|
}
|
package/src/util/reaper.mjs
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
import { execFileSync } from 'node:child_process';
|
|
30
|
-
import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
30
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
31
31
|
import path from 'node:path';
|
|
32
32
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
33
33
|
|
|
@@ -85,7 +85,7 @@ export function isAlive(pid) {
|
|
|
85
85
|
export function readStartTime(pid) {
|
|
86
86
|
if (!isValidPid(pid)) return null;
|
|
87
87
|
try {
|
|
88
|
-
const out = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
|
|
88
|
+
const out = execFileSync(existsSync('/bin/ps') ? '/bin/ps' : 'ps', ['-o', 'lstart=', '-p', String(pid)], {
|
|
89
89
|
encoding: 'utf8',
|
|
90
90
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
91
91
|
env: { ...process.env, LC_ALL: 'C', LANG: 'C' },
|
|
@@ -165,7 +165,7 @@ export function groupMembers(pgid) {
|
|
|
165
165
|
const members = new Map();
|
|
166
166
|
let out = '';
|
|
167
167
|
try {
|
|
168
|
-
out = execFileSync('ps', ['-A', '-o', 'pid=,pgid=,lstart='], {
|
|
168
|
+
out = execFileSync(existsSync('/bin/ps') ? '/bin/ps' : 'ps', ['-A', '-o', 'pid=,pgid=,lstart='], {
|
|
169
169
|
encoding: 'utf8',
|
|
170
170
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
171
171
|
env: { ...process.env, LC_ALL: 'C', LANG: 'C' },
|
package/src/worker/engine.mjs
CHANGED
|
@@ -95,13 +95,13 @@ import { askJev } from '../decide/jev-client.mjs';
|
|
|
95
95
|
import { planAndRecord, tierOf } from '../review/budget.mjs';
|
|
96
96
|
import { budgetFor } from '../review/packet.mjs';
|
|
97
97
|
import { ContextError, reviewDigest } from '../context/pack.mjs';
|
|
98
|
-
import { reviewFile, rulesRisk } from '../review/engine.mjs';
|
|
98
|
+
import { reviewFile, reviewLightBatch, rulesRisk } from '../review/engine.mjs';
|
|
99
99
|
import { reviewAnswers } from '../review/answers.mjs';
|
|
100
100
|
import { peerDiffReader } from '../review/moved.mjs';
|
|
101
101
|
import { computeFileSet, ownsFile } from '../gates/scope.mjs';
|
|
102
102
|
import { extraRoundsFor, newFileState, reopenForExtraRound, runRound } from '../review/fixloop.mjs';
|
|
103
103
|
import { reopenRowFor } from '../review/reopen.mjs';
|
|
104
|
-
import { readRun } from '../state/run.mjs';
|
|
104
|
+
import { processStartTime, readRun } from '../state/run.mjs';
|
|
105
105
|
import { StateError } from '../state/paths.mjs';
|
|
106
106
|
import { blockTree, worktreeRecord } from '../proof/worktree.mjs';
|
|
107
107
|
import { ledgerSchemaHistory } from '../review/schema-fallback.mjs';
|
|
@@ -109,7 +109,7 @@ import { verifyRow } from '../state/signer.mjs';
|
|
|
109
109
|
import { redact } from '../util/redact.mjs';
|
|
110
110
|
import { withLock } from '../util/file-locks.mjs';
|
|
111
111
|
import { blockAnchors, blockRungUsed, loadState, RESTORED_EVENT, saveState } from './review-state.mjs';
|
|
112
|
-
import { assertAnswers } from './queue.mjs';
|
|
112
|
+
import { assertAnswers, enqueueHash, isDone, writeResult } from './queue.mjs';
|
|
113
113
|
import { assertTicketId, contentHash, readRegularFileNoFollow, WorkerError } from './ticket.mjs';
|
|
114
114
|
|
|
115
115
|
/**
|
|
@@ -127,6 +127,8 @@ import { assertTicketId, contentHash, readRegularFileNoFollow, WorkerError } fro
|
|
|
127
127
|
* the worker's and a per-call `cfg` wins).
|
|
128
128
|
* @property {(row: Record<string, any>) => Promise<unknown>} [writeRow] - a signed ledger row
|
|
129
129
|
* (the worker adds `run`, `block`, `file`, `content_hash`).
|
|
130
|
+
* @property {(row: Record<string, any>) => Promise<unknown>} [writeBlockRow] - light fanout rows;
|
|
131
|
+
* the worker pins run and block, while the batch supplies each file and content hash.
|
|
130
132
|
* @property {Buffer} [key] - the run's HMAC key: signs the fix-loop state. Absent ⇒ engine only.
|
|
131
133
|
* @property {() => Promise<Array<Record<string, any>>>} [readRows] - the run's ledger rows (the
|
|
132
134
|
* `review.state` anchors the state is checked against); required with `key`.
|
|
@@ -169,7 +171,7 @@ import { assertTicketId, contentHash, readRegularFileNoFollow, WorkerError } fro
|
|
|
169
171
|
* `tree` (B62b) is the recorded `worktree` `{path, branch, base_sha}`, null when the block has
|
|
170
172
|
* none; `base` is then the tree's `base_sha`. A recorded worktree that `worktreeRecord` refuses
|
|
171
173
|
* (path, branch, hex base_sha) throws `worktree-malformed`.
|
|
172
|
-
* @returns {Promise<{base: string | null, level: string, owned: string[], ownedKinds: Record<string, string>, testFiles: string[], acceptance?: unknown, kind: string | null, tree: WorktreeEntry | null, workspace: string | null}>}
|
|
174
|
+
* @returns {Promise<{base: string | null, level: string, owned: string[], ownedKinds: Record<string, string>, testFiles: string[], acceptance?: unknown, acceptance_revision?: string, kind: string | null, tree: WorktreeEntry | null, workspace: string | null}>}
|
|
173
175
|
*/
|
|
174
176
|
export async function blockEntryFor(runId, block) {
|
|
175
177
|
let record;
|
|
@@ -197,7 +199,7 @@ export async function blockEntryFor(runId, block) {
|
|
|
197
199
|
throw new Error('worktree-malformed');
|
|
198
200
|
}
|
|
199
201
|
}
|
|
200
|
-
return { base: tree ? tree.base_sha : sha, level: typeof entry.level === 'string' && /^L[0-3]$/.test(entry.level) ? entry.level : 'L2', owned, ownedKinds, testFiles, acceptance: entry.acceptance, kind: blockKind({ owned, declared: entry.kind }), tree, workspace: typeof record.workspace === 'string' ? record.workspace : null };
|
|
202
|
+
return { base: tree ? tree.base_sha : sha, level: typeof entry.level === 'string' && /^L[0-3]$/.test(entry.level) ? entry.level : 'L2', owned, ownedKinds, testFiles, acceptance: entry.acceptance, acceptance_revision: entry.acceptance_revision, kind: entry.kind === 'light' ? 'light' : blockKind({ owned, declared: entry.kind }), tree, workspace: typeof record.workspace === 'string' ? record.workspace : null };
|
|
201
203
|
}
|
|
202
204
|
|
|
203
205
|
/** @typedef {{path: string, branch: string, base_sha: string}} WorktreeEntry */
|
|
@@ -352,6 +354,9 @@ export async function reviewTicket(ticket, ctx) {
|
|
|
352
354
|
return { status: 'unavailable', reason: err instanceof Error ? err.message : 'run-record-unreadable', approved: false, engine: 'adaptive', sessions: [] };
|
|
353
355
|
}
|
|
354
356
|
if (entry.kind === null) entry = { ...entry, kind: blockKind({ owned: [ticket.file] }) };
|
|
357
|
+
if (!ticket.kind && ticket.acceptance_revision !== entry.acceptance_revision) {
|
|
358
|
+
return { status: 'stale', reason: 'acceptance-amended', approved: false, engine: 'adaptive', sessions: [] };
|
|
359
|
+
}
|
|
355
360
|
// B62b: every content read of this ticket happens in the block's tree (the main tree without
|
|
356
361
|
// one), taken from the SAME entry as the base and the facts. The worker passes the tree it
|
|
357
362
|
// hashed the ticket in as `ctx.contentRoot`; a record that changed since names another tree.
|
|
@@ -411,6 +416,10 @@ export async function reviewTicket(ticket, ctx) {
|
|
|
411
416
|
return { status: 'unavailable', reason: 'context-unreadable', approved: false, engine: 'adaptive', sessions: [] };
|
|
412
417
|
}
|
|
413
418
|
const digest = rulesDigest === '' ? {} : { rulesDigest };
|
|
419
|
+
if (entry.kind === 'light' && ctx.key) {
|
|
420
|
+
const batch = await lightRound(ticket, tctx, { entry, workDir, factsExcerpt, rulesDigest });
|
|
421
|
+
if (batch) return batch;
|
|
422
|
+
}
|
|
414
423
|
if (!ctx.key) {
|
|
415
424
|
return await reviewFile(
|
|
416
425
|
{ repoRoot: contentRoot, file: ticket.file, base: entry.base, cfg: ctx.cfg, kind: entry.kind, workDir, factsExcerpt, ...digest, peerDiffs, ...(await fileContext()) },
|
|
@@ -423,6 +432,56 @@ export async function reviewTicket(ticket, ctx) {
|
|
|
423
432
|
}
|
|
424
433
|
}
|
|
425
434
|
|
|
435
|
+
/** Serialize a light block across tickets/workers; durable signed rows enforce the two-batch cap.
|
|
436
|
+
* A batch completes every file's queue ticket, including files queued after this ticket started.
|
|
437
|
+
* @param {import('./queue.mjs').Ticket} ticket @param {ReviewContext} ctx
|
|
438
|
+
* @param {{entry: Awaited<ReturnType<typeof blockEntryFor>>, workDir: string, factsExcerpt: string, rulesDigest: string}} input
|
|
439
|
+
* @returns {Promise<ReviewOutcome | null>} null enters the ordinary per-file loop.
|
|
440
|
+
*/
|
|
441
|
+
async function lightRound(ticket, ctx, { entry, workDir, factsExcerpt, rulesDigest }) {
|
|
442
|
+
if (!ctx.key || !ctx.readRows || !ctx.writeBlockRow) return { status: 'unavailable', reason: 'no-ledger', approved: false, engine: 'adaptive', sessions: [] };
|
|
443
|
+
const key = ctx.key, readRows = ctx.readRows, writeRow = ctx.writeBlockRow;
|
|
444
|
+
const worker = { pid: process.pid, started_at: await processStartTime(process.pid) };
|
|
445
|
+
return withLock(`light:${ctx.runId}\0${ticket.block}`, async () => {
|
|
446
|
+
const root = ctx.contentRoot ?? ctx.repoRoot;
|
|
447
|
+
const files = (await computeFileSet({ cwd: root, base: entry.base ?? 'HEAD' })).all.filter((file) => ownsFile(entry.owned, file, entry.ownedKinds)).sort();
|
|
448
|
+
const hashes = files.map((file) => ({ file, content_hash: contentHash(root, file) }));
|
|
449
|
+
if (!hashes.some((f) => f.file === ticket.file && f.content_hash === ticket.content_hash)) return { status: 'stale', approved: false, engine: 'adaptive', sessions: [] };
|
|
450
|
+
const signature = JSON.stringify([entry.base, hashes]);
|
|
451
|
+
const rows = (await readRows()).filter((r) => r.run === ctx.runId && r.block === ticket.block && verifyRow(r, key).ok);
|
|
452
|
+
const cached = rows.filter((r) => r.event === 'review.light_result' && r.signature === signature).at(-1)?.outcomes?.[ticket.file];
|
|
453
|
+
if (cached && (cached.status === 'reviewed' || isDone(ctx.repoRoot, ticket.ticket))) return cached;
|
|
454
|
+
const count = rows.filter((r) => r.event === 'review.light_batch').length;
|
|
455
|
+
if (count >= 2) return null;
|
|
456
|
+
const round = count + 1;
|
|
457
|
+
// Reserve before the session: a crash cannot reset the cap on a worker restart.
|
|
458
|
+
await writeRow({ event: 'review.light_batch', round, signature });
|
|
459
|
+
const batch = await reviewLightBatch({ repoRoot: root, files, base: entry.base, cfg: ctx.cfg, workDir, factsExcerpt, rulesDigest }, { spawn: ctx.spawn, writeRow });
|
|
460
|
+
const moved = hashes.some((f) => contentHash(root, f.file) !== f.content_hash);
|
|
461
|
+
const findings = batch.findings ?? [];
|
|
462
|
+
const blocking = findings.some((f) => f.severity === 'critical' || f.severity === 'warning');
|
|
463
|
+
/** @type {Record<string, ReviewOutcome>} */
|
|
464
|
+
const outcomes = {};
|
|
465
|
+
for (const { file, content_hash } of hashes) {
|
|
466
|
+
const own = findings.filter((f) => f.file === file);
|
|
467
|
+
const approved = !moved && batch.status === 'reviewed' && (batch.approved || blocking) && own.every((f) => f.severity === 'nit');
|
|
468
|
+
const outcome = { ...batch, ...(moved ? { status: 'stale' } : {}), approved, findings: own, round, kind: 'full', level: entry.level,
|
|
469
|
+
next: { action: approved ? 'complete' : 'fix', level: entry.level } };
|
|
470
|
+
outcomes[file] = outcome;
|
|
471
|
+
if (batch.status === 'reviewed' && !moved) {
|
|
472
|
+
await writeRow({ event: 'review.round', file, content_hash, round, kind: 'full', level: entry.level, open_after: own.filter((f) => f.severity !== 'nit').length });
|
|
473
|
+
if (approved) await writeRow({ event: 'review.approved', file, content_hash });
|
|
474
|
+
}
|
|
475
|
+
const queued = enqueueHash({ repoRoot: ctx.repoRoot, run: ctx.runId, block: ticket.block, file, content_hash, ...(ticket.tree ? { tree: ticket.tree } : {}) });
|
|
476
|
+
const open = approved ? [] : own;
|
|
477
|
+
await writeRow({ event: 'review.result', ticket: queued.ticket, file, content_hash, status: outcome.status, approved, open, worker });
|
|
478
|
+
writeResult({ repoRoot: ctx.repoRoot, runId: ctx.runId, ticket: queued.ticket, result: { ...outcome, block: ticket.block, file, content_hash, open, worker }, key });
|
|
479
|
+
}
|
|
480
|
+
await writeRow({ event: 'review.light_result', signature, outcomes });
|
|
481
|
+
return outcomes[ticket.file];
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
|
|
426
485
|
/**
|
|
427
486
|
* B56: the block's OTHER changed files — the §4.1 file set since the block base (tracked changes ∪
|
|
428
487
|
* untracked), filtered to the block's owned files (`ownsFile`, as the budget row does), minus the
|
|
@@ -565,7 +624,16 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, block
|
|
|
565
624
|
seq: loaded.seq,
|
|
566
625
|
contentHash: ticket.content_hash,
|
|
567
626
|
};
|
|
568
|
-
|
|
627
|
+
let amendedAt = -1;
|
|
628
|
+
let anchoredAt = -1;
|
|
629
|
+
rows.forEach((row, i) => {
|
|
630
|
+
if (row.block !== ticket.block || !verifyRow(row, key).ok) return;
|
|
631
|
+
if (row.event === 'block.acceptance_amended') amendedAt = i;
|
|
632
|
+
if (row.event === 'review.state' && row.file === ticket.file) anchoredAt = i;
|
|
633
|
+
});
|
|
634
|
+
// A changed review input starts a full round, including previously stopped files. Keep
|
|
635
|
+
// the anchor sequence and the block's spent L3 rung; tampered state still refuses above.
|
|
636
|
+
const reopened = amendedAt > anchoredAt || reopenRowFor(rows, reopenAt) !== null;
|
|
569
637
|
if (loaded.status === 'lost' && !reopened) return { outcome: /** @type {ReviewOutcome} */ ({ status: 'refused', reason: 'review-state-lost', message: LOST_MESSAGE, approved: false, engine: 'adaptive', sessions: [] }) };
|
|
570
638
|
let seq = loaded.seq;
|
|
571
639
|
if (loaded.status === 'restored' && loaded.state !== null && !reopened) {
|
package/src/worker/loop.mjs
CHANGED
|
@@ -42,12 +42,13 @@ import path from 'node:path';
|
|
|
42
42
|
import { fileURLToPath } from 'node:url';
|
|
43
43
|
import { expiryChecks } from '../autopilot/limits.mjs';
|
|
44
44
|
import { loadProjectConfig } from '../config/load.mjs';
|
|
45
|
+
import { reviewWorkers } from '../config/validate.mjs';
|
|
45
46
|
import { cacheHoursFrom, createDefaultKeyStore, resolveKey } from '../keys/store.mjs';
|
|
46
47
|
import { appendRow, readAllRows } from '../ledger/write.mjs';
|
|
47
48
|
import { spawnSession } from '../session/spawn.mjs';
|
|
48
49
|
import { snapshotFor } from '../state/config-snapshot.mjs';
|
|
49
50
|
import { runRecordPath } from '../state/paths.mjs';
|
|
50
|
-
import { readRun, reattachWorker } from '../state/run.mjs';
|
|
51
|
+
import { processStartTime, readRun, reattachWorker } from '../state/run.mjs';
|
|
51
52
|
import { loadKey, signRow } from '../state/signer.mjs';
|
|
52
53
|
import { logWarning } from '../util/error-log.mjs';
|
|
53
54
|
import { withLock, tryLock } from '../util/file-locks.mjs';
|
|
@@ -260,6 +261,7 @@ export function recordedVerdict(result) {
|
|
|
260
261
|
*/
|
|
261
262
|
export async function createWorker(opts, deps = {}) {
|
|
262
263
|
const { runId, repoRoot, cfg, runRootDir, slug, key } = opts;
|
|
264
|
+
const identity = { pid: process.pid, started_at: await processStartTime(process.pid) };
|
|
263
265
|
const env = deps.env ?? process.env;
|
|
264
266
|
const review = deps.review ?? reviewTicket;
|
|
265
267
|
const spawn = deps.spawn ?? spawnSession;
|
|
@@ -382,6 +384,7 @@ export async function createWorker(opts, deps = {}) {
|
|
|
382
384
|
{ env: childEnv, ...(deps.bins ? { bins: deps.bins } : {}), ...(deps.stderr ? { stderr: deps.stderr } : {}) },
|
|
383
385
|
),
|
|
384
386
|
writeRow: (row) => ledger({ ...row, block: ticket.block, file: ticket.file, content_hash: ticket.content_hash }),
|
|
387
|
+
writeBlockRow: (row) => ledger({ ...row, block: ticket.block }),
|
|
385
388
|
};
|
|
386
389
|
try {
|
|
387
390
|
const outcome = await review(ticket, ctx);
|
|
@@ -484,12 +487,14 @@ export async function createWorker(opts, deps = {}) {
|
|
|
484
487
|
* @param {string} id @param {Record<string, any>} outcome - recorded through {@link recordedVerdict}.
|
|
485
488
|
*/
|
|
486
489
|
async function complete(id, outcome) {
|
|
487
|
-
|
|
490
|
+
/** @type {Record<string, any>} */
|
|
491
|
+
const result = { ...recordedVerdict(outcome), worker: identity };
|
|
488
492
|
const signed = writeResult({ repoRoot, runId, ticket: id, result, key });
|
|
489
493
|
try {
|
|
490
494
|
await ledger({
|
|
491
495
|
event: 'review.result',
|
|
492
496
|
ticket: id,
|
|
497
|
+
worker: identity,
|
|
493
498
|
block: result.block ?? null,
|
|
494
499
|
file: result.file ?? null,
|
|
495
500
|
content_hash: result.content_hash ?? null,
|
|
@@ -793,9 +798,6 @@ export async function createWorker(opts, deps = {}) {
|
|
|
793
798
|
return { processTicket, drain, run, once, stop, release, announce, watchTick: tickWatch, jevKeyResolved: jevKey !== null, sessionEnv: childEnv };
|
|
794
799
|
}
|
|
795
800
|
|
|
796
|
-
/** This block permits one registered worker per queue. */
|
|
797
|
-
const WORKER_LIMIT = 1;
|
|
798
|
-
|
|
799
801
|
/**
|
|
800
802
|
* Start-up for the `worker` verb: run record, repo root, config, run temp root (owned by this
|
|
801
803
|
* process), run key; refuses when another live worker already serves this queue.
|
|
@@ -831,7 +833,10 @@ export async function bootWorker(opts, deps = {}) {
|
|
|
831
833
|
const legacy = liveWorker(repoRoot);
|
|
832
834
|
if (legacy && !entries.some((entry) => entry.worker?.pid === legacy.pid && entry.worker.start_time === legacy.start_time)) count++;
|
|
833
835
|
await deps.hooks?.afterCount?.(count);
|
|
834
|
-
|
|
836
|
+
const limit = reviewWorkers(cfg);
|
|
837
|
+
if (entries.some((entry) => entry.live && !entry.worker) || (legacy && legacy.run !== opts.runId) || entries.some((entry) => entry.live && entry.worker?.run !== opts.runId) || count >= limit) {
|
|
838
|
+
throw new WorkerError('worker-running', `review.workers limit ${limit}: a live or unreadable worker entry already serves this queue`);
|
|
839
|
+
}
|
|
835
840
|
worker.announce();
|
|
836
841
|
});
|
|
837
842
|
return { ...worker, repoRoot, runRootDir };
|
|
@@ -844,10 +849,10 @@ export async function bootWorker(opts, deps = {}) {
|
|
|
844
849
|
* `expectPid` (a `run start` that just launched a worker) pins ONLY that pid: an older or stale
|
|
845
850
|
* announcement naming another live pid is refused (`worker_mismatch`) instead of being pinned
|
|
846
851
|
* over the new worker.
|
|
847
|
-
* @param {{runId: string, repoRoot: string, writeRow?: (row: Record<string, any>) => Promise<unknown>, probe?: import('../state/run.mjs').StartTimeProbe, expectPid?: number}} opts
|
|
852
|
+
* @param {{runId: string, repoRoot: string, writeRow?: (row: Record<string, any>) => Promise<unknown>, probe?: import('../state/run.mjs').StartTimeProbe, expectPid?: number, add?: boolean}} opts
|
|
848
853
|
*/
|
|
849
|
-
export async function repinWorker({ runId, repoRoot, writeRow, probe, expectPid }) {
|
|
850
|
-
const live = liveWorker(repoRoot);
|
|
854
|
+
export async function repinWorker({ runId, repoRoot, writeRow, probe, expectPid, add = false }) {
|
|
855
|
+
const live = (expectPid === undefined ? null : liveWorker(repoRoot, { pid: expectPid, registeredOnly: true })) ?? liveWorker(repoRoot);
|
|
851
856
|
if (!live) throw new WorkerError('worker_down', 'no live worker serves this queue');
|
|
852
857
|
if (live.run !== runId) throw new WorkerError('other-run', `the live worker serves run ${live.run}`);
|
|
853
858
|
if (expectPid !== undefined && live.pid !== expectPid) {
|
|
@@ -855,7 +860,7 @@ export async function repinWorker({ runId, repoRoot, writeRow, probe, expectPid
|
|
|
855
860
|
}
|
|
856
861
|
const { project } = await readRun(runId);
|
|
857
862
|
const write = writeRow ?? ((/** @type {Record<string, any>} */ row) => appendRow(row, { slug: project }));
|
|
858
|
-
return reattachWorker({ runId, workerPid: live.pid, writeRow: write, ...(probe ? { probe } : {}) });
|
|
863
|
+
return reattachWorker({ runId, workerPid: live.pid, writeRow: write, add, ...(probe ? { probe } : {}) });
|
|
859
864
|
}
|
|
860
865
|
|
|
861
866
|
/** The CLI entry the worker is launched from. */
|
|
@@ -887,5 +892,9 @@ export async function launchWorker({ runId, workspace, env = process.env, timeou
|
|
|
887
892
|
// already gone
|
|
888
893
|
}
|
|
889
894
|
}
|
|
895
|
+
const other = liveWorker(repoRoot);
|
|
896
|
+
if (other && other.run !== runId) {
|
|
897
|
+
throw new WorkerError('worker_down', `the worker did not start: run ${other.run} still has a live worker (pid ${other.pid}); end it with: code-forge run end --run ${other.run}`);
|
|
898
|
+
}
|
|
890
899
|
throw new WorkerError('worker_down', 'the worker did not start (run `code-forge worker --run <id>` by hand to see why)');
|
|
891
900
|
}
|