@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.
Files changed (79) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/README.md +5 -1
  3. package/docs/README.md +2 -0
  4. package/docs/reference/blocks.json +162 -1
  5. package/docs/reference/config.md +38 -15
  6. package/native/README.md +10 -0
  7. package/native/bin/darwin-arm64/code-forge-native +0 -0
  8. package/native/bin/darwin-x64/code-forge-native +0 -0
  9. package/native/bin/linux-arm64/code-forge-native +0 -0
  10. package/native/bin/linux-x64/code-forge-native +0 -0
  11. package/package.json +4 -1
  12. package/schema/code-forge.schema.json +83 -3
  13. package/schema/outcome.schema.json +45 -0
  14. package/schema/pipeline.schema.json +30 -0
  15. package/scripts/first-use.mjs +8 -0
  16. package/scripts/gen-config-doc.mjs +1 -1
  17. package/skill/references/autopilot.md +6 -1
  18. package/skill/references/code.md +52 -0
  19. package/skill/references/ledger.md +13 -1
  20. package/skill/references/plan.md +5 -1
  21. package/skill/references/review.md +46 -8
  22. package/skill/scripts/forge +1 -1
  23. package/src/cli/block.mjs +427 -38
  24. package/src/cli/context.mjs +17 -7
  25. package/src/cli/doctor.mjs +32 -1
  26. package/src/cli/gates.mjs +76 -10
  27. package/src/cli/help.mjs +5 -0
  28. package/src/cli/native-status.mjs +13 -0
  29. package/src/cli/outcome.mjs +87 -0
  30. package/src/cli/pipeline.mjs +61 -0
  31. package/src/cli/proof.mjs +10 -7
  32. package/src/cli/report.mjs +53 -6
  33. package/src/cli/review-file.mjs +278 -41
  34. package/src/cli/run.mjs +3 -2
  35. package/src/cli/status.mjs +43 -9
  36. package/src/cli/sweep.mjs +28 -0
  37. package/src/cli/validate.mjs +6 -0
  38. package/src/config/validate.mjs +83 -1
  39. package/src/context/pack.mjs +53 -3
  40. package/src/doctor/index.mjs +11 -0
  41. package/src/doctor/local.mjs +22 -4
  42. package/src/doctor/worker-probe.mjs +1 -1
  43. package/src/engines/builders/codex.mjs +27 -7
  44. package/src/engines/builders/grok.mjs +11 -5
  45. package/src/engines/codex-home.mjs +18 -15
  46. package/src/engines/sentinel.mjs +11 -0
  47. package/src/gates/detect.mjs +6 -3
  48. package/src/gates/run.mjs +295 -27
  49. package/src/ledger/export.mjs +1 -1
  50. package/src/ledger/report.mjs +143 -5
  51. package/src/outcome/outcome.mjs +108 -0
  52. package/src/pipeline/pipeline.mjs +112 -0
  53. package/src/proof/red-green.mjs +41 -13
  54. package/src/proof/runners.mjs +28 -4
  55. package/src/proof/worktree.mjs +25 -15
  56. package/src/review/agent-waiver.mjs +113 -12
  57. package/src/review/engine.mjs +25 -5
  58. package/src/review/gate-check.mjs +52 -10
  59. package/src/review/lenses/full.md +1 -0
  60. package/src/review/lenses/quick.md +1 -0
  61. package/src/review/model-stats.mjs +125 -0
  62. package/src/review/packet.mjs +88 -9
  63. package/src/review/related.mjs +112 -0
  64. package/src/review/reopen.mjs +42 -16
  65. package/src/review/route.mjs +58 -4
  66. package/src/review/session-retry.mjs +59 -0
  67. package/src/session/facts.mjs +19 -7
  68. package/src/session/plan-check.mjs +4 -1
  69. package/src/session/spawn.mjs +54 -3
  70. package/src/state/block.mjs +139 -49
  71. package/src/state/config-snapshot.mjs +13 -3
  72. package/src/state/run.mjs +3 -2
  73. package/src/util/exec.mjs +48 -31
  74. package/src/util/native.mjs +131 -0
  75. package/src/util/reaper.mjs +238 -8
  76. package/src/worker/engine.mjs +7 -3
  77. package/src/worker/loop.mjs +211 -30
  78. package/src/worker/queue.mjs +107 -24
  79. 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
- // B87: and never had a proof runner
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 whole process GROUP is sent SIGTERM, then SIGKILL — the SIGKILL goes to the
12
- * group as soon as the direct child exits (or after a grace period), so a grandchild that
13
- * traps SIGTERM cannot outlive the call. The result is `failed`, never a partial success.
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 { registerPid, unregisterPid } from './reaper.mjs';
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} result */
220
- const settle = (result) => {
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
- // Once we've decided to kill (timeout or over-buffer), don't wait for 'close' — a
291
- // grandchild that inherited the pipes can keep them open indefinitely, and 'close' would
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 (timedOut || overBuffer) {
298
- killGroup('SIGKILL');
299
- settle({
300
- result: 'failed',
301
- code,
302
- signal,
303
- stdout: stdoutText(),
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
- const result = !timedOut && !overBuffer && code !== null && okExitCodes.includes(code) ? 'ok' : 'failed';
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
+ }
@@ -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 (pid === process.pid) continue;
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
- if (readStartTime(pid) !== started) return;
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
  }
@@ -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 });