@codedology/code-forge 0.15.2 → 0.15.4
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 +81 -0
- package/README.md +5 -1
- package/docs/README.md +2 -0
- package/docs/reference/blocks.json +162 -1
- package/docs/reference/config.md +38 -15
- package/native/README.md +10 -0
- package/native/bin/darwin-arm64/code-forge-native +0 -0
- package/native/bin/darwin-x64/code-forge-native +0 -0
- package/native/bin/linux-arm64/code-forge-native +0 -0
- package/native/bin/linux-x64/code-forge-native +0 -0
- package/package.json +4 -1
- package/schema/code-forge.schema.json +83 -3
- package/schema/outcome.schema.json +45 -0
- package/schema/pipeline.schema.json +30 -0
- package/scripts/first-use.mjs +8 -0
- package/scripts/gen-config-doc.mjs +1 -1
- package/skill/references/autopilot.md +6 -1
- package/skill/references/code.md +52 -0
- package/skill/references/ledger.md +13 -1
- package/skill/references/plan.md +5 -1
- package/skill/references/review.md +46 -8
- package/skill/scripts/forge +1 -1
- package/src/cli/block.mjs +427 -38
- package/src/cli/context.mjs +17 -7
- package/src/cli/doctor.mjs +32 -1
- package/src/cli/gates.mjs +76 -10
- package/src/cli/help.mjs +5 -0
- package/src/cli/native-status.mjs +13 -0
- package/src/cli/outcome.mjs +87 -0
- package/src/cli/pipeline.mjs +61 -0
- package/src/cli/proof.mjs +10 -7
- package/src/cli/report.mjs +53 -6
- package/src/cli/review-file.mjs +278 -41
- package/src/cli/run.mjs +3 -2
- package/src/cli/status.mjs +43 -9
- package/src/cli/sweep.mjs +28 -0
- package/src/cli/validate.mjs +6 -0
- package/src/config/validate.mjs +83 -1
- package/src/context/pack.mjs +53 -3
- package/src/doctor/index.mjs +11 -0
- package/src/doctor/local.mjs +22 -4
- package/src/doctor/worker-probe.mjs +1 -1
- package/src/engines/builders/codex.mjs +27 -7
- package/src/engines/builders/grok.mjs +11 -5
- package/src/engines/codex-home.mjs +18 -15
- package/src/engines/sentinel.mjs +11 -0
- package/src/gates/detect.mjs +6 -3
- package/src/gates/run.mjs +295 -27
- package/src/ledger/export.mjs +1 -1
- package/src/ledger/report.mjs +143 -5
- package/src/outcome/outcome.mjs +108 -0
- package/src/pipeline/pipeline.mjs +112 -0
- package/src/proof/red-green.mjs +41 -13
- package/src/proof/runners.mjs +28 -4
- package/src/proof/worktree.mjs +25 -15
- package/src/review/agent-waiver.mjs +113 -12
- package/src/review/engine.mjs +25 -5
- package/src/review/gate-check.mjs +52 -10
- package/src/review/lenses/full.md +1 -0
- package/src/review/lenses/quick.md +1 -0
- package/src/review/model-stats.mjs +125 -0
- package/src/review/packet.mjs +88 -9
- package/src/review/related.mjs +112 -0
- package/src/review/reopen.mjs +42 -16
- package/src/review/route.mjs +58 -4
- package/src/review/session-retry.mjs +59 -0
- package/src/session/facts.mjs +19 -7
- package/src/session/plan-check.mjs +4 -1
- package/src/session/spawn.mjs +54 -3
- package/src/state/block.mjs +139 -49
- package/src/state/config-snapshot.mjs +13 -3
- package/src/state/run.mjs +3 -2
- package/src/util/exec.mjs +48 -31
- package/src/util/native.mjs +131 -0
- package/src/util/reaper.mjs +238 -8
- package/src/worker/engine.mjs +7 -3
- package/src/worker/loop.mjs +211 -30
- package/src/worker/queue.mjs +107 -24
- package/src/worker/watch.mjs +18 -7
package/src/state/run.mjs
CHANGED
|
@@ -275,8 +275,7 @@ export async function reloadRun({ runId, config, readPending = () => [], effecti
|
|
|
275
275
|
if (config.context?.require_coder_tests === true) frozen.push('context.require_coder_tests');
|
|
276
276
|
// B64b: and never had a block-end hook
|
|
277
277
|
if (config.hooks?.on_block_end !== undefined) frozen.push('hooks.on_block_end');
|
|
278
|
-
//
|
|
279
|
-
if (config.proof?.runners !== undefined) frozen.push('proof.runners');
|
|
278
|
+
// B171: a legacy run has no existing runners; adding its first list is safe.
|
|
280
279
|
// B93: and never had a coder sandbox profile
|
|
281
280
|
if (config.coder?.sandbox !== undefined) frozen.push('coder.sandbox');
|
|
282
281
|
}
|
|
@@ -324,6 +323,8 @@ export async function reattachWorker({ runId, workerPid, writeRow, probe = proce
|
|
|
324
323
|
if (current.status !== 'active') throw new StateError('run-ended', `run ${runId} has ended`);
|
|
325
324
|
const pin = await pinWorker(workerPid, probe);
|
|
326
325
|
const pins = current.workers ?? (current.worker ? [current.worker] : []);
|
|
326
|
+
// Retired pins still authorize completed reviews, but never live-worker checks or signals.
|
|
327
|
+
if (!add) current.worker_history = [...(current.worker_history ?? []), ...pins];
|
|
327
328
|
current.workers = add ? [...pins, pin] : [pin];
|
|
328
329
|
current.worker = current.workers[0];
|
|
329
330
|
await writeSigned(runId, writeRow, { event: add ? 'worker.add' : 'worker.reattach', pid: workerPid });
|
package/src/util/exec.mjs
CHANGED
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
* indefinitely (callers that spawn a reviewer/coder CLI are expected to always pass one —
|
|
9
9
|
* this module does not impose a default, since a default here would silently apply to every
|
|
10
10
|
* caller, including ones with a legitimately long-running command).
|
|
11
|
-
* - On timeout the
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* - On timeout the process group receives SIGTERM, then SIGKILL after a grace period while
|
|
12
|
+
* the leader lives. On every leader exit, remaining members are captured and stopped
|
|
13
|
+
* individually after checking their start times. Timeout always resolves `failed`.
|
|
14
14
|
* - stdin is `ignore`d by default, so a child that reads stdin when it isn't a TTY (`claude -p`,
|
|
15
15
|
* for one) sees an immediate EOF instead of blocking forever. With `input` (a string or bytes),
|
|
16
16
|
* stdin is a pipe: the content is written verbatim and then the pipe is closed, so the child
|
|
@@ -50,7 +50,8 @@
|
|
|
50
50
|
*/
|
|
51
51
|
|
|
52
52
|
import { spawn } from 'node:child_process';
|
|
53
|
-
import
|
|
53
|
+
import path from 'node:path';
|
|
54
|
+
import { groupMembers, readStartTime, registerPid, stopMembers, unregisterPid } from './reaper.mjs';
|
|
54
55
|
import { pidsDir } from './tmp.mjs';
|
|
55
56
|
|
|
56
57
|
/** Grace period after SIGTERM before escalating to SIGKILL, in milliseconds. */
|
|
@@ -170,16 +171,32 @@ export function exec(argv, opts = {}) {
|
|
|
170
171
|
const { cwd, env, timeoutMs, input, okExitCodes = [0], maxBufferBytes = DEFAULT_MAX_BUFFER_BYTES } = opts;
|
|
171
172
|
const [command, ...args] = argv;
|
|
172
173
|
|
|
174
|
+
/** @type {NodeJS.ProcessEnv} */
|
|
175
|
+
const childEnv = { ...(env ?? process.env), CODE_FORGE_OWNER_PID: String(process.pid) };
|
|
176
|
+
const ownerStart = readStartTime(process.pid);
|
|
177
|
+
if (ownerStart) childEnv.CODE_FORGE_OWNER_START_TIME = ownerStart;
|
|
178
|
+
else delete childEnv.CODE_FORGE_OWNER_START_TIME;
|
|
179
|
+
let spawnRegistry = null;
|
|
180
|
+
try {
|
|
181
|
+
spawnRegistry = pidsDir();
|
|
182
|
+
const rootId = path.basename(path.dirname(spawnRegistry));
|
|
183
|
+
const runAt = process.argv.indexOf('--run');
|
|
184
|
+
const inlineRun = process.argv.find((arg) => arg.startsWith('--run='))?.slice('--run='.length);
|
|
185
|
+
const runId = childEnv.CODE_FORGE_RUN_ID ?? process.env.CODE_FORGE_RUN_ID
|
|
186
|
+
?? (runAt >= 0 ? process.argv[runAt + 1] : inlineRun) ?? (/^r-/.test(rootId) ? rootId : undefined);
|
|
187
|
+
if (runId) childEnv.CODE_FORGE_RUN_ID = runId;
|
|
188
|
+
} catch { /* registry hygiene must never prevent a spawn */ }
|
|
189
|
+
|
|
173
190
|
return new Promise((resolve) => {
|
|
174
191
|
const stdinMode = input === undefined ? 'ignore' : 'pipe';
|
|
175
|
-
const child = spawn(command, args, { cwd, env, shell: false, detached: true, stdio: [stdinMode, 'pipe', 'pipe'] });
|
|
192
|
+
const child = spawn(command, args, { cwd, env: childEnv, shell: false, detached: true, stdio: [stdinMode, 'pipe', 'pipe'] });
|
|
176
193
|
const pid = child.pid;
|
|
177
194
|
/** @type {string | null} */
|
|
178
195
|
let registryDir = null;
|
|
179
196
|
if (typeof pid === 'number') {
|
|
180
197
|
trackChild(pid);
|
|
181
198
|
try {
|
|
182
|
-
registryDir = pidsDir();
|
|
199
|
+
registryDir = spawnRegistry ?? pidsDir();
|
|
183
200
|
registerPid(registryDir, pid, command);
|
|
184
201
|
} catch {
|
|
185
202
|
registryDir = null;
|
|
@@ -200,6 +217,7 @@ export function exec(argv, opts = {}) {
|
|
|
200
217
|
let timedOut = false;
|
|
201
218
|
let overBuffer = false;
|
|
202
219
|
let settled = false;
|
|
220
|
+
let remaining = Promise.resolve();
|
|
203
221
|
/** @type {NodeJS.Timeout | undefined} */
|
|
204
222
|
let termTimer;
|
|
205
223
|
/** @type {NodeJS.Timeout | undefined} */
|
|
@@ -216,12 +234,24 @@ export function exec(argv, opts = {}) {
|
|
|
216
234
|
const stdoutText = () => Buffer.concat(stdoutChunks).toString(opts.stdoutEncoding ?? 'utf8');
|
|
217
235
|
const stderrText = () => Buffer.concat(stderrChunks).toString('utf8');
|
|
218
236
|
|
|
219
|
-
/** @param {ExecResult}
|
|
220
|
-
const settle = (
|
|
237
|
+
/** @param {Pick<ExecResult, 'code'|'signal'|'error'>} outcome */
|
|
238
|
+
const settle = async (outcome) => {
|
|
221
239
|
if (settled) return;
|
|
222
240
|
settled = true;
|
|
223
241
|
if (termTimer) clearTimeout(termTimer);
|
|
224
242
|
if (killTimer) clearTimeout(killTimer);
|
|
243
|
+
await remaining;
|
|
244
|
+
// Pipe data can arrive while the remaining group members are being stopped.
|
|
245
|
+
// Snapshot the output and buffer-limit state only once that wait is over.
|
|
246
|
+
/** @type {ExecResult} */
|
|
247
|
+
const result = {
|
|
248
|
+
...outcome,
|
|
249
|
+
result: !outcome.error && !timedOut && !overBuffer && outcome.code !== null && okExitCodes.includes(outcome.code) ? 'ok' : 'failed',
|
|
250
|
+
stdout: stdoutText(),
|
|
251
|
+
stderr: stderrText(),
|
|
252
|
+
timedOut,
|
|
253
|
+
...(overBuffer ? { error: 'exec: maxBufferBytes exceeded' } : {}),
|
|
254
|
+
};
|
|
225
255
|
if (typeof pid === 'number') {
|
|
226
256
|
untrackChild(pid);
|
|
227
257
|
if (registryDir !== null) {
|
|
@@ -277,40 +307,27 @@ export function exec(argv, opts = {}) {
|
|
|
277
307
|
|
|
278
308
|
child.on('error', (err) => {
|
|
279
309
|
settle({
|
|
280
|
-
result: 'failed',
|
|
281
310
|
code: null,
|
|
282
311
|
signal: null,
|
|
283
|
-
stdout: stdoutText(),
|
|
284
|
-
stderr: stderrText(),
|
|
285
|
-
timedOut,
|
|
286
312
|
error: err.message,
|
|
287
313
|
});
|
|
288
314
|
});
|
|
289
315
|
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
// then never fire even though the child we care about is dead. 'exit' fires as soon as the
|
|
293
|
-
// child process itself has terminated, which is what "killed after timeout_ms" means. Before
|
|
294
|
-
// settling (which cancels the grace timer), SIGKILL the group: a grandchild that trapped the
|
|
295
|
-
// SIGTERM must not survive just because its parent died first.
|
|
316
|
+
// Capture the group before unregistering on EVERY leader exit. Never signal the dead
|
|
317
|
+
// leader/group again: each remaining member has its own synchronous identity check.
|
|
296
318
|
child.on('exit', (code, signal) => {
|
|
297
|
-
if (
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
stderr: stderrText(),
|
|
305
|
-
timedOut,
|
|
306
|
-
...(overBuffer ? { error: 'exec: maxBufferBytes exceeded' } : {}),
|
|
307
|
-
});
|
|
319
|
+
if (termTimer) clearTimeout(termTimer);
|
|
320
|
+
if (killTimer) clearTimeout(killTimer);
|
|
321
|
+
const members = typeof pid === 'number' ? groupMembers(pid) : new Map();
|
|
322
|
+
if (typeof pid === 'number') untrackChild(pid);
|
|
323
|
+
remaining = stopMembers(members, KILL_GRACE_MS).then(() => {});
|
|
324
|
+
if (timedOut || overBuffer || members.size > 0) {
|
|
325
|
+
settle({ code, signal });
|
|
308
326
|
}
|
|
309
327
|
});
|
|
310
328
|
|
|
311
329
|
child.on('close', (code, signal) => {
|
|
312
|
-
|
|
313
|
-
settle({ result, code, signal, stdout: stdoutText(), stderr: stderrText(), timedOut });
|
|
330
|
+
settle({ code, signal });
|
|
314
331
|
});
|
|
315
332
|
});
|
|
316
333
|
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { accessSync, constants, statSync } from 'node:fs';
|
|
3
|
+
import net from 'node:net';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import pkg from '../../package.json' with { type: 'json' };
|
|
8
|
+
import { exec } from './exec.mjs';
|
|
9
|
+
|
|
10
|
+
/** @returns {string | null} the optional executable shipped for this host. */
|
|
11
|
+
export function nativePath() {
|
|
12
|
+
if (process.env.CODE_FORGE_NATIVE === '0') return null;
|
|
13
|
+
const binary = fileURLToPath(new URL(`../../native/bin/${process.platform}-${process.arch}/code-forge-native`, import.meta.url));
|
|
14
|
+
try {
|
|
15
|
+
if (!statSync(binary).isFile()) return null;
|
|
16
|
+
accessSync(binary, constants.X_OK);
|
|
17
|
+
return binary;
|
|
18
|
+
} catch { return null; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {string[]} args
|
|
23
|
+
* @returns {Promise<import('./exec.mjs').ExecResult | null>} null requests the Node fallback.
|
|
24
|
+
*/
|
|
25
|
+
export async function runNative(args) {
|
|
26
|
+
const binary = nativePath();
|
|
27
|
+
return binary ? exec([binary, ...args], { timeoutMs: 30_000 }) : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** @param {Record<string, any> | null} [config] @param {NodeJS.ProcessEnv} [env] */
|
|
31
|
+
export function daemonEnabled(config, env = process.env) {
|
|
32
|
+
return env.CODE_FORGE_NATIVE !== '0' && config?.native?.daemon !== false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* One connection per call. Ping negotiates the package version before sending the method;
|
|
37
|
+
* no socket is opened when disabled. Protocol and transport failures request Node fallback.
|
|
38
|
+
* @param {string} run
|
|
39
|
+
* @param {{method: string, block?: string, max_ms?: number}} request
|
|
40
|
+
* @param {{config?: Record<string, any> | null, timeoutMs?: number}} [opts]
|
|
41
|
+
* @returns {Promise<Record<string, any> | null>}
|
|
42
|
+
*/
|
|
43
|
+
export async function daemonRequest(run, request, { config, timeoutMs = request.method === 'wait-block' ? (request.max_ms ?? 0) + 2500 : 6500 } = {}) {
|
|
44
|
+
if (!daemonEnabled(config) || !/^[a-z0-9][a-z0-9-]{0,63}$/.test(run)) return null;
|
|
45
|
+
return new Promise((resolve) => {
|
|
46
|
+
let settled = false;
|
|
47
|
+
let negotiated = false;
|
|
48
|
+
let buffer = '';
|
|
49
|
+
const socket = net.createConnection(path.join(os.homedir(), '.code-forge', 'runs', run, 'native.sock'));
|
|
50
|
+
socket.setEncoding('utf8');
|
|
51
|
+
const finish = (/** @type {Record<string, any> | null} */ value) => {
|
|
52
|
+
if (settled) return;
|
|
53
|
+
settled = true;
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
socket.destroy();
|
|
56
|
+
resolve(value);
|
|
57
|
+
};
|
|
58
|
+
let timer = setTimeout(() => finish(null), Math.min(2000, timeoutMs));
|
|
59
|
+
const deadline = (/** @type {number} */ ms) => {
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
timer = setTimeout(() => finish(null), ms);
|
|
62
|
+
};
|
|
63
|
+
socket.on('error', () => finish(null));
|
|
64
|
+
socket.on('close', () => finish(null));
|
|
65
|
+
socket.on('connect', () => {
|
|
66
|
+
deadline(Math.min(2000, timeoutMs));
|
|
67
|
+
socket.write('{"method":"ping"}\n');
|
|
68
|
+
});
|
|
69
|
+
socket.on('data', (chunk) => {
|
|
70
|
+
buffer += chunk;
|
|
71
|
+
if (buffer.length > 8 * 1024 * 1024) return finish(null);
|
|
72
|
+
const end = buffer.indexOf('\n');
|
|
73
|
+
if (end < 0) return;
|
|
74
|
+
let answer;
|
|
75
|
+
try { answer = JSON.parse(buffer.slice(0, end)); } catch { return finish(null); }
|
|
76
|
+
buffer = buffer.slice(end + 1);
|
|
77
|
+
if (!answer || typeof answer !== 'object' || Array.isArray(answer) || answer.ok === false) return finish(null);
|
|
78
|
+
if (!negotiated) {
|
|
79
|
+
if (answer.ok !== true || answer.version !== pkg.version || answer.run !== run) return finish(null);
|
|
80
|
+
if (request.method === 'ping') return finish(answer);
|
|
81
|
+
negotiated = true;
|
|
82
|
+
deadline(timeoutMs);
|
|
83
|
+
socket.write(`${JSON.stringify(request)}\n`);
|
|
84
|
+
} else {
|
|
85
|
+
const { stats, ...result } = answer;
|
|
86
|
+
if (request.method === 'wait-block' && (result.block !== request.block || !Array.isArray(result.files) || typeof result.done !== 'boolean'
|
|
87
|
+
|| !result.files.every((/** @type {any} */ file) => file && typeof file.file === 'string' && typeof file.status === 'string'))) return finish(null);
|
|
88
|
+
if (request.method === 'status' && (!Array.isArray(result.runs) || result.runs.length !== 1 || result.runs[0]?.run_id !== run || !Array.isArray(result.runs[0]?.blocks))) return finish(null);
|
|
89
|
+
finish(result);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}).catch(() => null);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The daemon serializes startup with its own OS lock. A loser exits harmlessly;
|
|
97
|
+
* only this child is stopped, never a daemon another worker already owns.
|
|
98
|
+
* @param {string} run @param {Record<string, any> | null} config
|
|
99
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
100
|
+
* @returns {Promise<() => Promise<void>>}
|
|
101
|
+
*/
|
|
102
|
+
export async function startNativeDaemon(run, config, env = process.env) {
|
|
103
|
+
const noop = async () => {};
|
|
104
|
+
if (!daemonEnabled(config, env)) return noop;
|
|
105
|
+
const binary = nativePath();
|
|
106
|
+
if (!binary || await daemonRequest(run, { method: 'ping' }, { config })) return noop;
|
|
107
|
+
let child;
|
|
108
|
+
try { child = spawn(binary, ['serve', '--home', os.homedir(), '--run', run], { stdio: 'ignore', env }); }
|
|
109
|
+
catch { return noop; }
|
|
110
|
+
let ended = false;
|
|
111
|
+
const exited = new Promise((resolve) => {
|
|
112
|
+
child.once('error', () => { ended = true; resolve(undefined); });
|
|
113
|
+
child.once('exit', () => { ended = true; resolve(undefined); });
|
|
114
|
+
});
|
|
115
|
+
for (let i = 0; i < 100 && !ended; i++) {
|
|
116
|
+
if (await daemonRequest(run, { method: 'ping' }, { config, timeoutMs: 100 })) break;
|
|
117
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
118
|
+
}
|
|
119
|
+
/** @type {Promise<void> | undefined} */
|
|
120
|
+
let stopping;
|
|
121
|
+
return () => {
|
|
122
|
+
stopping ??= (async () => {
|
|
123
|
+
if (ended) return;
|
|
124
|
+
child.kill('SIGTERM');
|
|
125
|
+
const timer = setTimeout(() => child.kill('SIGKILL'), 2000);
|
|
126
|
+
timer.unref();
|
|
127
|
+
try { await exited; } finally { clearTimeout(timer); }
|
|
128
|
+
})();
|
|
129
|
+
return stopping;
|
|
130
|
+
};
|
|
131
|
+
}
|
package/src/util/reaper.mjs
CHANGED
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
import { execFileSync } from 'node:child_process';
|
|
30
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
30
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
31
|
+
import os from 'node:os';
|
|
31
32
|
import path from 'node:path';
|
|
32
33
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
33
34
|
|
|
@@ -186,6 +187,220 @@ export function groupMembers(pgid) {
|
|
|
186
187
|
return members;
|
|
187
188
|
}
|
|
188
189
|
|
|
190
|
+
/** @typedef {{uid:number,pid:number,ppid:number,state:string,rssMb:number,start_time:string,command:string}} ProcessInfo */
|
|
191
|
+
|
|
192
|
+
/** Parse C-locale ps output; malformed rows supply no authority to signal.
|
|
193
|
+
* @param {string} text @returns {ProcessInfo[]}
|
|
194
|
+
*/
|
|
195
|
+
export function parseProcesses(text) {
|
|
196
|
+
return text.split('\n').flatMap((line) => {
|
|
197
|
+
const m = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(\d+)\s+(\S+\s+\S+\s+\d+\s+\d+:\d+:\d+\s+\d{4})\s+(.+?)\s*$/.exec(line);
|
|
198
|
+
return m ? [{ uid: +m[1], pid: +m[2], ppid: +m[3], state: m[4], rssMb: +m[5] / 1024,
|
|
199
|
+
start_time: m[6].replace(/\s+/g, ' '), command: m[7] }] : [];
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** @returns {ProcessInfo[]} */
|
|
204
|
+
export function readProcesses() {
|
|
205
|
+
try {
|
|
206
|
+
return parseProcesses(execFileSync(existsSync('/bin/ps') ? '/bin/ps' : 'ps',
|
|
207
|
+
['-A', '-ww', '-o', 'uid=,pid=,ppid=,stat=,rss=,lstart=,command='], {
|
|
208
|
+
encoding: 'utf8', env: { ...process.env, LC_ALL: 'C', LANG: 'C' },
|
|
209
|
+
timeout: 5000, maxBuffer: 16 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'],
|
|
210
|
+
}));
|
|
211
|
+
} catch { return []; }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Current process, ancestors and zombies are never signal targets.
|
|
215
|
+
* @param {ProcessInfo[]} rows @returns {Set<number>}
|
|
216
|
+
*/
|
|
217
|
+
function protectedPids(rows) {
|
|
218
|
+
const blocked = new Set([1, process.pid, process.ppid]);
|
|
219
|
+
const byPid = new Map(rows.map((p) => [p.pid, p]));
|
|
220
|
+
let pid = process.ppid;
|
|
221
|
+
const visited = new Set();
|
|
222
|
+
while (pid > 1 && !visited.has(pid)) {
|
|
223
|
+
visited.add(pid);
|
|
224
|
+
blocked.add(pid);
|
|
225
|
+
pid = byPid.get(pid)?.ppid ?? 1;
|
|
226
|
+
}
|
|
227
|
+
for (const p of rows) if (p.state.startsWith('Z')) blocked.add(p.pid);
|
|
228
|
+
return blocked;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** One synchronous identity/uid/state check immediately followed by a bare-pid signal.
|
|
232
|
+
* @param {number} pid @param {string} started @param {NodeJS.Signals} signal
|
|
233
|
+
* @returns {boolean}
|
|
234
|
+
*/
|
|
235
|
+
function signalMember(pid, started, signal) {
|
|
236
|
+
if (!started || started === UNKNOWN_START_TIME || !process.getuid) return false;
|
|
237
|
+
const rows = readProcesses();
|
|
238
|
+
const p = rows.find((r) => r.pid === pid);
|
|
239
|
+
if (!p || p.uid !== process.getuid() || protectedPids(rows).has(pid) || p.start_time !== started) return false;
|
|
240
|
+
try { process.kill(pid, signal); return true; } catch { return false; }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Stop a captured set, checking every member separately at both signals.
|
|
244
|
+
* @param {Map<number,string>} members @param {number} [graceMs]
|
|
245
|
+
* @returns {Promise<Map<number, 'SIGTERM'|'SIGKILL'>>}
|
|
246
|
+
*/
|
|
247
|
+
export async function stopMembers(members, graceMs = DEFAULT_GRACE_MS) {
|
|
248
|
+
const sent = new Map();
|
|
249
|
+
for (const [pid, started] of members) if (signalMember(pid, started, 'SIGTERM')) sent.set(pid, 'SIGTERM');
|
|
250
|
+
const deadline = Date.now() + graceMs;
|
|
251
|
+
while (sent.size && Date.now() < deadline) {
|
|
252
|
+
const live = readProcesses();
|
|
253
|
+
if (![...sent.keys()].some((pid) => live.some((p) => p.pid === pid && !p.state.startsWith('Z') && p.start_time === members.get(pid)))) break;
|
|
254
|
+
await sleep(POLL_MS);
|
|
255
|
+
}
|
|
256
|
+
for (const pid of sent.keys()) if (signalMember(pid, members.get(pid), 'SIGKILL')) sent.set(pid, 'SIGKILL');
|
|
257
|
+
// Delivery is not exit: wait for SIGKILL to take effect before exec can return to its caller.
|
|
258
|
+
const killedDeadline = Date.now() + Math.max(graceMs, 1000);
|
|
259
|
+
while (sent.size && Date.now() < killedDeadline) {
|
|
260
|
+
await sleep(POLL_MS);
|
|
261
|
+
const live = readProcesses();
|
|
262
|
+
if (![...sent.keys()].some((pid) => live.some((p) => p.pid === pid && !p.state.startsWith('Z') && p.start_time === members.get(pid)))) break;
|
|
263
|
+
}
|
|
264
|
+
return sent;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Read only ownership markers, never retain/print the rest of a child's environment.
|
|
268
|
+
* @param {ProcessInfo} p @returns {Record<string,string>|null}
|
|
269
|
+
*/
|
|
270
|
+
export function readProcessEnv(p) {
|
|
271
|
+
try {
|
|
272
|
+
let text;
|
|
273
|
+
if (process.platform === 'linux') text = readFileSync(`/proc/${p.pid}/environ`, 'utf8');
|
|
274
|
+
else if (process.platform === 'darwin') {
|
|
275
|
+
const out = execFileSync('/bin/ps', ['-E', '-ww', '-o', 'pid=,ppid=,lstart=,command=', '-p', String(p.pid)], {
|
|
276
|
+
encoding: 'utf8', timeout: 5000, maxBuffer: 8 * 1024 * 1024,
|
|
277
|
+
env: { ...process.env, LC_ALL: 'C', LANG: 'C' }, stdio: ['ignore', 'pipe', 'ignore'],
|
|
278
|
+
});
|
|
279
|
+
const m = /^\s*(\d+)\s+(\d+)\s+(\S+\s+\S+\s+\d+\s+\d+:\d+:\d+\s+\d{4})\s+/.exec(out);
|
|
280
|
+
if (!m || +m[1] !== p.pid || +m[2] !== p.ppid || m[3].replace(/\s+/g, ' ') !== p.start_time) return null;
|
|
281
|
+
text = out.slice(m[0].length);
|
|
282
|
+
// ps appends the environment to argv. An ownership-looking argument is not an env
|
|
283
|
+
// marker: strip the independently observed command before parsing the environment.
|
|
284
|
+
if (!text.startsWith(`${p.command} `)) return null;
|
|
285
|
+
text = text.slice(p.command.length + 1);
|
|
286
|
+
} else return null;
|
|
287
|
+
/** @type {Record<string,string>} */
|
|
288
|
+
const markers = {};
|
|
289
|
+
for (const name of ['HOME', 'CODE_FORGE_OWNER_PID', 'CODE_FORGE_RUN_ID', 'CODE_FORGE_OWNER_START_TIME']) {
|
|
290
|
+
const match = process.platform === 'linux'
|
|
291
|
+
? text.split('\0').find((entry) => entry.startsWith(`${name}=`))?.slice(name.length + 1)
|
|
292
|
+
: new RegExp(`(?:^|\\s)${name}=(.*?)(?=\\s+[A-Za-z_][A-Za-z0-9_]*=|$)`).exec(text)?.[1];
|
|
293
|
+
if (match !== undefined) markers[name] = match;
|
|
294
|
+
}
|
|
295
|
+
return markers;
|
|
296
|
+
} catch { return null; }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Inspect and optionally stop proven code-forge processes. Defaults are read-only.
|
|
301
|
+
* `psText`/readers are test seams; production always probes the OS and existing record reader.
|
|
302
|
+
* @param {{apply?:boolean, root?:string, runId?:string, orphansOnly?:boolean, zombies?:boolean,
|
|
303
|
+
* graceMs?:number, psText?:string, uid?:number,
|
|
304
|
+
* readEnv?:(p:ProcessInfo)=>Record<string,string>|null,
|
|
305
|
+
* readRun?:(id:string)=>Promise<Record<string,any>>}} [opts]
|
|
306
|
+
*/
|
|
307
|
+
export async function sweepProcesses(opts = {}) {
|
|
308
|
+
const rows = opts.psText === undefined ? readProcesses() : parseProcesses(opts.psText);
|
|
309
|
+
const uid = opts.uid ?? process.getuid?.();
|
|
310
|
+
const own = rows.filter((p) => p.uid === uid);
|
|
311
|
+
const blocked = protectedPids(rows);
|
|
312
|
+
const envFor = opts.readEnv ?? readProcessEnv;
|
|
313
|
+
const loadRun = opts.readRun ?? (await import('../state/run.mjs')).readRun;
|
|
314
|
+
const tmp = await import('./tmp.mjs');
|
|
315
|
+
/** @type {(ProcessInfo & {kind:'worker'|'orphan'|'registry',runId?:string,action?:string})[]} */
|
|
316
|
+
const items = [];
|
|
317
|
+
/** @type {{ppid:number,command:string,count:number,pids:number[]}[]} */
|
|
318
|
+
const zombies = [];
|
|
319
|
+
if (opts.zombies !== false && !opts.orphansOnly) {
|
|
320
|
+
for (const p of own.filter((p) => p.state.startsWith('Z'))) {
|
|
321
|
+
let group = zombies.find((g) => g.ppid === p.ppid);
|
|
322
|
+
if (!group) { group = { ppid: p.ppid, command: rows.find((r) => r.pid === p.ppid)?.command ?? '(unreadable)', count: 0, pids: [] }; zombies.push(group); }
|
|
323
|
+
group.count++; group.pids.push(p.pid);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
for (const p of own) {
|
|
327
|
+
if (p.pid <= 1 || blocked.has(p.pid)) continue;
|
|
328
|
+
// Exactly node + a code-forge entry point + worker, never an argv substring in a shell/-e.
|
|
329
|
+
const worker = /^(?:\S*\/)?node(?:js)?\s+(?:\S*\/)?code-forge\.mjs\s+worker\s+--run(?:=|\s+)([a-z0-9][a-z0-9-]{0,63})(?:\s|$)/.exec(p.command);
|
|
330
|
+
if (worker && !opts.orphansOnly) {
|
|
331
|
+
// Run records only authorize worker cleanup inside their own code-forge home.
|
|
332
|
+
// Unreadable environments and workers from another HOME supply no such authority.
|
|
333
|
+
if (envFor(p)?.HOME !== os.homedir()) continue;
|
|
334
|
+
try {
|
|
335
|
+
const record = await loadRun(worker[1]);
|
|
336
|
+
if (typeof record.status === 'string' && !['open', 'running', 'active'].includes(record.status)) items.push({ ...p, kind: 'worker', runId: worker[1] });
|
|
337
|
+
} catch (err) {
|
|
338
|
+
if (err?.code === 'no-run') items.push({ ...p, kind: 'worker', runId: worker[1] });
|
|
339
|
+
}
|
|
340
|
+
// The run record is authoritative for workers, including when it cannot be read.
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
if (p.ppid !== 1 || items.some((item) => item.pid === p.pid)) continue;
|
|
344
|
+
const env = envFor(p);
|
|
345
|
+
const owner = env?.CODE_FORGE_OWNER_PID;
|
|
346
|
+
if (!owner || !/^[1-9]\d*$/.test(owner) || +owner <= 1 || (opts.runId && env.CODE_FORGE_RUN_ID !== opts.runId)) continue;
|
|
347
|
+
const liveOwner = rows.find((r) => r.pid === +owner);
|
|
348
|
+
const recorded = env.CODE_FORGE_OWNER_START_TIME;
|
|
349
|
+
// A missing row alone is not proof of death when ps was incomplete.
|
|
350
|
+
const dead = !liveOwner ? !isAlive(+owner) : liveOwner.state.startsWith('Z')
|
|
351
|
+
|| !!recorded && recorded !== UNKNOWN_START_TIME && liveOwner.start_time !== recorded;
|
|
352
|
+
if (dead) items.push({ ...p, kind: 'orphan', ...(env.CODE_FORGE_RUN_ID ? { runId: env.CODE_FORGE_RUN_ID } : {}) });
|
|
353
|
+
}
|
|
354
|
+
const root = tmp.tmpBase(opts.root);
|
|
355
|
+
const registryRoots = [];
|
|
356
|
+
if (!opts.orphansOnly && tmp.untrustedReason(root) === null) {
|
|
357
|
+
for (const dir of readdirSync(root, { withFileTypes: true })) {
|
|
358
|
+
const full = path.join(root, dir.name);
|
|
359
|
+
if (!dir.isDirectory() || tmp.untrustedReason(full) !== null) continue;
|
|
360
|
+
const state = tmp.ownerState(full);
|
|
361
|
+
let reap = state === 'dead';
|
|
362
|
+
if (state === 'missing') {
|
|
363
|
+
// Mirror sweepRoots' ten-minute ownerless grace, including every registry mtime.
|
|
364
|
+
try {
|
|
365
|
+
const pids = path.join(full, 'pids');
|
|
366
|
+
const files = existsSync(pids) ? [pids, ...readdirSync(pids).map((name) => path.join(pids, name))] : [];
|
|
367
|
+
reap = [full, ...files].every((file) => Date.now() - lstatSync(file).mtimeMs > 600_000);
|
|
368
|
+
} catch { reap = false; }
|
|
369
|
+
}
|
|
370
|
+
if (!reap) continue;
|
|
371
|
+
registryRoots.push(full);
|
|
372
|
+
for (const entry of listEntries(path.join(full, 'pids'))) {
|
|
373
|
+
const p = own.find((p) => p.pid === entry.pid && p.start_time === entry.start_time);
|
|
374
|
+
if (!p || blocked.has(p.pid)) continue;
|
|
375
|
+
const members = new Map([[p.pid, p.start_time], ...groupMembers(p.pid)]);
|
|
376
|
+
for (const [pid, started] of members) {
|
|
377
|
+
const member = own.find((p) => p.pid === pid && p.start_time === started);
|
|
378
|
+
if (member && !blocked.has(pid) && !items.some((i) => i.pid === pid)) items.push({ ...member, kind: 'registry' });
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
// Memory across all code-forge trees, deduplicated. Unmarked descendants are children too.
|
|
384
|
+
const forge = new Set(own.filter((p) => /^(?:\S*\/)?node(?:js)?\s+(?:\S*\/)?code-forge\.mjs(?:\s|$)/.test(p.command) || p.pid === process.pid).map((p) => p.pid));
|
|
385
|
+
const tree = new Set(forge);
|
|
386
|
+
let size;
|
|
387
|
+
do { size = tree.size; for (const p of own) if (tree.has(p.ppid)) tree.add(p.pid); } while (tree.size !== size);
|
|
388
|
+
const sum = (/** @type {ProcessInfo[]} */ ps) => ps.reduce((n, p) => n + p.rssMb, 0);
|
|
389
|
+
const memory = { codeForgeMb: sum(own.filter((p) => forge.has(p.pid))), childrenMb: sum(own.filter((p) => tree.has(p.pid) && !forge.has(p.pid))), orphansMb: sum(items.filter((p) => p.kind === 'orphan')) };
|
|
390
|
+
if (opts.apply) {
|
|
391
|
+
for (const item of items.filter((p) => p.kind !== 'registry')) {
|
|
392
|
+
const stopped = await stopMembers(new Map([[item.pid, item.start_time]]), opts.graceMs);
|
|
393
|
+
item.action = stopped.has(item.pid) ? 'stopped' : 'skipped';
|
|
394
|
+
}
|
|
395
|
+
if (!opts.orphansOnly) await tmp.sweepRoots({ root, graceMs: opts.graceMs });
|
|
396
|
+
for (const item of items.filter((p) => p.kind === 'registry')) {
|
|
397
|
+
const started = readStartTime(item.pid);
|
|
398
|
+
item.action = !isAlive(item.pid) || (started !== null && started !== item.start_time) ? 'stopped' : 'skipped';
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return { apply: opts.apply === true, items, registryRoots, zombies, totalRssMb: sum(items), memory };
|
|
402
|
+
}
|
|
403
|
+
|
|
189
404
|
/**
|
|
190
405
|
* Send `signal` to `pid`'s group — only after a SYNCHRONOUS start-time match, with no await
|
|
191
406
|
* between the check and the kill. Falls back to the bare pid only when the group does not exist
|
|
@@ -196,6 +411,19 @@ export function groupMembers(pgid) {
|
|
|
196
411
|
* @returns {'sent'|'mismatch'|'eperm'|'gone'}
|
|
197
412
|
*/
|
|
198
413
|
function signalIfOurs(pid, recorded, signal) {
|
|
414
|
+
const rows = readProcesses();
|
|
415
|
+
const blocked = protectedPids(rows);
|
|
416
|
+
const leader = rows.find((p) => p.pid === pid);
|
|
417
|
+
if (!process.getuid || !leader || leader.uid !== process.getuid() || blocked.has(pid)) return 'eperm';
|
|
418
|
+
const members = groupMembers(pid);
|
|
419
|
+
const safeGroup = [...members].every(([member, started]) => rows.some((p) => p.pid === member
|
|
420
|
+
&& p.uid === process.getuid() && !blocked.has(member) && p.start_time === started));
|
|
421
|
+
if (!safeGroup) {
|
|
422
|
+
if (readStartTime(pid) !== recorded) return 'mismatch';
|
|
423
|
+
const sent = signalMember(pid, recorded, signal);
|
|
424
|
+
for (const [member, started] of members) signalMember(member, started, signal);
|
|
425
|
+
return sent ? 'sent' : 'eperm';
|
|
426
|
+
}
|
|
199
427
|
if (readStartTime(pid) !== recorded) return 'mismatch';
|
|
200
428
|
try {
|
|
201
429
|
process.kill(-pid, signal);
|
|
@@ -234,10 +462,16 @@ export async function sweep(dir, opts = {}) {
|
|
|
234
462
|
const { graceMs = DEFAULT_GRACE_MS } = opts;
|
|
235
463
|
/** @type {SweepResult} */
|
|
236
464
|
const result = { killed: [], mismatched: [], unknown: [], stale: [] };
|
|
465
|
+
const rows = readProcesses();
|
|
466
|
+
const blocked = protectedPids(rows);
|
|
237
467
|
|
|
238
468
|
for (const entry of listEntries(dir)) {
|
|
239
469
|
const { pid } = entry;
|
|
240
|
-
if (
|
|
470
|
+
if (blocked.has(pid)) continue;
|
|
471
|
+
if (isAlive(pid) && !rows.some((p) => p.pid === pid && p.uid === process.getuid?.())) {
|
|
472
|
+
result.unknown.push(pid);
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
241
475
|
|
|
242
476
|
if (!isAlive(pid)) {
|
|
243
477
|
unregisterPid(dir, pid);
|
|
@@ -278,6 +512,7 @@ export async function sweep(dir, opts = {}) {
|
|
|
278
512
|
continue;
|
|
279
513
|
}
|
|
280
514
|
await waitForDeath(pid, graceMs);
|
|
515
|
+
for (const [member, started] of members) signalMemberIfOurs(member, started);
|
|
281
516
|
result.killed.push({ pid, signal: 'SIGKILL' });
|
|
282
517
|
}
|
|
283
518
|
unregisterPid(dir, pid);
|
|
@@ -291,10 +526,5 @@ export async function sweep(dir, opts = {}) {
|
|
|
291
526
|
* @param {number} pid @param {string} started
|
|
292
527
|
*/
|
|
293
528
|
function signalMemberIfOurs(pid, started) {
|
|
294
|
-
|
|
295
|
-
try {
|
|
296
|
-
process.kill(pid, 'SIGKILL');
|
|
297
|
-
} catch {
|
|
298
|
-
// gone between the check and the kill
|
|
299
|
-
}
|
|
529
|
+
signalMember(pid, started, 'SIGKILL');
|
|
300
530
|
}
|
package/src/worker/engine.mjs
CHANGED
|
@@ -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, enqueueHash, isDone, writeResult } from './queue.mjs';
|
|
112
|
+
import { assertAnswers, enqueueHash, isDone, reviewExclusionFor, writeResult } from './queue.mjs';
|
|
113
113
|
import { assertTicketId, contentHash, readRegularFileNoFollow, WorkerError } from './ticket.mjs';
|
|
114
114
|
|
|
115
115
|
/**
|
|
@@ -345,6 +345,9 @@ export function packetDirFor(runRootDir, id) {
|
|
|
345
345
|
* @returns {Promise<ReviewOutcome>}
|
|
346
346
|
*/
|
|
347
347
|
export async function reviewTicket(ticket, ctx) {
|
|
348
|
+
const excluded = (/** @type {string} */ file) => reviewExclusionFor(ctx.repoRoot, ctx.runId, file, ctx.cfg);
|
|
349
|
+
const rule = excluded(ticket.file) ?? (ticket.kind === 'answers' ? excluded(ticket.by_file) : null);
|
|
350
|
+
if (rule !== null) return { status: 'excluded', reason: `review.exclude ${rule}`, approved: false, engine: 'adaptive', sessions: [] };
|
|
348
351
|
const workDir = packetDirFor(ctx.runRootDir, ticket.ticket);
|
|
349
352
|
if (workDir === null) return { status: 'unavailable', reason: 'ticket-id-invalid', approved: false, engine: 'adaptive', sessions: [] };
|
|
350
353
|
let entry;
|
|
@@ -390,7 +393,7 @@ export async function reviewTicket(ticket, ctx) {
|
|
|
390
393
|
try {
|
|
391
394
|
// Read the listing directly: blockPeers' [] fallback would hide a git failure and
|
|
392
395
|
// still authorize explicit tests. Context and peer diffs must fail soft together.
|
|
393
|
-
const peers = entry.base === null || entry.owned.length === 0 ? [] : (await computeFileSet({ cwd: contentRoot, base: entry.base })).all.filter((f) => f !== ticket.file && ownsFile(entry.owned, f, entry.ownedKinds));
|
|
396
|
+
const peers = entry.base === null || entry.owned.length === 0 ? [] : (await computeFileSet({ cwd: contentRoot, base: entry.base })).all.filter((f) => f !== ticket.file && ownsFile(entry.owned, f, entry.ownedKinds) && excluded(f) === null);
|
|
394
397
|
readDiffs = peerDiffReader({ repoRoot: contentRoot, base: entry.base, file: ticket.file, peers });
|
|
395
398
|
const changedFiles = [...peers, ticket.file].filter((f) => ownsFile(entry.owned, f, entry.ownedKinds));
|
|
396
399
|
return { peers, changedFiles, ownedFiles: [...new Set([...changedFiles, ...entry.testFiles])], testFiles: entry.testFiles };
|
|
@@ -444,7 +447,7 @@ async function lightRound(ticket, ctx, { entry, workDir, factsExcerpt, rulesDige
|
|
|
444
447
|
const worker = { pid: process.pid, started_at: await processStartTime(process.pid) };
|
|
445
448
|
return withLock(`light:${ctx.runId}\0${ticket.block}`, async () => {
|
|
446
449
|
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();
|
|
450
|
+
const files = (await computeFileSet({ cwd: root, base: entry.base ?? 'HEAD' })).all.filter((file) => ownsFile(entry.owned, file, entry.ownedKinds) && reviewExclusionFor(ctx.repoRoot, ctx.runId, file, ctx.cfg) === null).sort();
|
|
448
451
|
const hashes = files.map((file) => ({ file, content_hash: contentHash(root, file) }));
|
|
449
452
|
if (!hashes.some((f) => f.file === ticket.file && f.content_hash === ticket.content_hash)) return { status: 'stale', approved: false, engine: 'adaptive', sessions: [] };
|
|
450
453
|
const signature = JSON.stringify([entry.base, hashes]);
|
|
@@ -473,6 +476,7 @@ async function lightRound(ticket, ctx, { entry, workDir, factsExcerpt, rulesDige
|
|
|
473
476
|
if (approved) await writeRow({ event: 'review.approved', file, content_hash });
|
|
474
477
|
}
|
|
475
478
|
const queued = enqueueHash({ repoRoot: ctx.repoRoot, run: ctx.runId, block: ticket.block, file, content_hash, ...(ticket.tree ? { tree: ticket.tree } : {}) });
|
|
479
|
+
if (queued.status === 'excluded') continue;
|
|
476
480
|
const open = approved ? [] : own;
|
|
477
481
|
await writeRow({ event: 'review.result', ticket: queued.ticket, file, content_hash, status: outcome.status, approved, open, worker });
|
|
478
482
|
writeResult({ repoRoot: ctx.repoRoot, runId: ctx.runId, ticket: queued.ticket, result: { ...outcome, block: ticket.block, file, content_hash, open, worker }, key });
|