@codedology/code-forge 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -0
- package/README.md +17 -1
- package/docs/reference/blocks.json +202 -1
- package/docs/reference/config.md +2 -1
- package/package.json +1 -1
- package/schema/code-forge.schema.json +2 -1
- package/skill/references/code.md +12 -5
- package/skill/references/review.md +12 -5
- package/skill/scripts/forge +1 -1
- package/src/autopilot/actions.mjs +4 -5
- package/src/cli/block.mjs +27 -0
- package/src/cli/context.mjs +3 -2
- package/src/cli/review-file.mjs +22 -9
- package/src/cli/review.mjs +2 -2
- package/src/context/pack.mjs +13 -9
- package/src/doctor/index.mjs +2 -1
- package/src/doctor/local.mjs +19 -0
- package/src/doctor/worker-probe.mjs +15 -2
- package/src/ledger/spend.mjs +3 -3
- package/src/proof/lock.mjs +5 -1
- package/src/review/gate-check.mjs +32 -7
- package/src/review/schema-fallback.mjs +3 -3
- package/src/util/file-locks.mjs +118 -0
- package/src/util/forbidden.mjs +29 -2
- package/src/worker/engine.mjs +2 -2
- package/src/worker/loop.mjs +83 -33
- package/src/worker/queue.mjs +123 -31
- package/src/worker/watch.mjs +4 -2
package/src/cli/block.mjs
CHANGED
|
@@ -193,6 +193,7 @@
|
|
|
193
193
|
* through `closeBlock`'s `extraChecks`.
|
|
194
194
|
*/
|
|
195
195
|
|
|
196
|
+
import { statSync } from 'node:fs';
|
|
196
197
|
import { readFile, realpath } from 'node:fs/promises';
|
|
197
198
|
import path from 'node:path';
|
|
198
199
|
import { parse as parseYAML } from 'yaml';
|
|
@@ -201,6 +202,7 @@ import { fallbackLane } from '../decide/fallback-rules.mjs';
|
|
|
201
202
|
import { laneFacts } from '../decide/lane-facts.mjs';
|
|
202
203
|
import { appendRow, readAllRows } from '../ledger/write.mjs';
|
|
203
204
|
import { attemptBlock, claimPath, closeBlock, openBlock, rebaseBlock, repoTop, stopBlock } from '../state/block.mjs';
|
|
205
|
+
import { lastGate } from '../state/brief-status.mjs';
|
|
204
206
|
import { intFlag, parseFlags } from '../state/cli-args.mjs';
|
|
205
207
|
import { configInForce, externalReviewer, requireCoderTests } from '../state/config-snapshot.mjs';
|
|
206
208
|
import { StateError } from '../state/paths.mjs';
|
|
@@ -288,6 +290,24 @@ const USAGE =
|
|
|
288
290
|
' every block verb takes --json: one JSON line on stdout ({"block","event",…}) instead of the text\n';
|
|
289
291
|
|
|
290
292
|
const SUBCOMMANDS = ['open', 'attempt', 'rebase', 'claim', 'close', 'stop', 'waive', 'reopen'];
|
|
293
|
+
|
|
294
|
+
/** Refuse likely shell quoting slips before any row is written.
|
|
295
|
+
* @param {string} workspace @param {string[]} entries @param {string} label
|
|
296
|
+
*/
|
|
297
|
+
function checkWhitespacePaths(workspace, entries, label) {
|
|
298
|
+
for (const entry of entries) {
|
|
299
|
+
if (!/\s/u.test(entry)) continue;
|
|
300
|
+
let stat;
|
|
301
|
+
try {
|
|
302
|
+
stat = statSync(path.join(workspace, entry));
|
|
303
|
+
} catch {
|
|
304
|
+
// A missing or unreadable path cannot establish a literal whitespace-containing name.
|
|
305
|
+
}
|
|
306
|
+
if (!stat?.isFile() && !stat?.isDirectory()) {
|
|
307
|
+
throw new StateError(label === 'owned files' ? 'bad-owned' : 'bad-tests', `${label}: invalid path ${JSON.stringify(entry)}: contains whitespace but names no existing file or directory`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
291
311
|
const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', 'lines', 'head', 'worker-pid', 'reason', 'file', 'transcript', 'kind', 'report'];
|
|
292
312
|
|
|
293
313
|
/**
|
|
@@ -345,6 +365,10 @@ export async function runBlock(args, deps = {}) {
|
|
|
345
365
|
if (sub === 'reopen' && typeof flags.file !== 'string') throw new StateError('usage', 'block reopen needs --file <path>');
|
|
346
366
|
if (sub === 'waive' && typeof flags.file !== 'string') throw new StateError('usage', 'block waive needs --file <path> (a finding id is scoped to one file)');
|
|
347
367
|
const record = await readRun(runId);
|
|
368
|
+
if (sub === 'open') {
|
|
369
|
+
checkWhitespacePaths(record.workspace, /** @type {string[]} */ (flags.owned), 'owned files');
|
|
370
|
+
if (Array.isArray(flags.tests)) checkWhitespacePaths(record.workspace, flags.tests, 'test files');
|
|
371
|
+
}
|
|
348
372
|
// B80: waive and reopen are the human's — asked at a terminal before anything is written (not
|
|
349
373
|
// even the autopilot expiry rows below); no terminal is exit 2, a "no" is exit 1
|
|
350
374
|
if (sub === 'waive' || sub === 'reopen') {
|
|
@@ -761,6 +785,9 @@ export async function runBlock(args, deps = {}) {
|
|
|
761
785
|
// B70: each file that passed as reviewed_by_waiver is named before the closed line
|
|
762
786
|
// B71: an answered finding is named as answered; a file with only answered findings has no `by`
|
|
763
787
|
if (result.ok) {
|
|
788
|
+
if (configInForce(record)?.gates?.test != null && lastGate(rows.filter((row) => row.block === id)).result !== 'pass') {
|
|
789
|
+
err(`WARN block ${id}: no green gates run for this block; run gates run --run ${runId} --block ${id} before close\n`);
|
|
790
|
+
}
|
|
764
791
|
for (const w of lineWarnings) err(w);
|
|
765
792
|
for (const l of reviewedJson) say(`reviewed ${l.file} ticket ${l.ticket}\n`);
|
|
766
793
|
for (const f of waivedAll) {
|
package/src/cli/context.mjs
CHANGED
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
* `<block>.md`. A row that cannot be appended removes only the temp file, so an earlier pack stays
|
|
17
17
|
* as it was and nothing was written. A rename that fails after the row was appended says so.
|
|
18
18
|
*
|
|
19
|
-
*
|
|
19
|
+
* With no `context` key, the pack holds only the block header and built-in proof rule (300 lines).
|
|
20
|
+
* Exit codes: 0 written, 1 refused (a rules or lessons file missing or outside
|
|
20
21
|
* the repository, a bad `context` value, a control character, an unknown run or block, a file or
|
|
21
22
|
* ledger write that fails), 2 usage. Never runs any command it lists.
|
|
22
23
|
*/
|
|
@@ -94,7 +95,7 @@ export async function runContext(args, deps = {}) {
|
|
|
94
95
|
if (!loaded.ok) throw new ContextError('bad-config', loaded.message);
|
|
95
96
|
const live = /** @type {Record<string, any>} */ (loaded.config);
|
|
96
97
|
const settings = contextSettings(live);
|
|
97
|
-
if (settings === null) throw new ContextError('
|
|
98
|
+
if (settings === null && Object.hasOwn(live, 'context')) throw new ContextError('bad-context', 'context must be a mapping');
|
|
98
99
|
const sources = readSources(repoRoot, settings);
|
|
99
100
|
const { text, lines, cut } = assemblePack({
|
|
100
101
|
settings,
|
package/src/cli/review-file.mjs
CHANGED
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
|
|
70
70
|
import { readFileSync, realpathSync, rmSync } from 'node:fs';
|
|
71
71
|
import path from 'node:path';
|
|
72
|
-
import { answersTicketId, assertAnswers, blockTickets, dropDoneIf, enqueue, enqueueAnswers, isDone, liveWorker, MAX_ANSWERS, queueDir, resolveTicket } from '../worker/queue.mjs';
|
|
72
|
+
import { answersTicketId, assertAnswers, blockTickets, deliverBlockFindings, dropDoneIf, enqueue, enqueueAnswers, isDone, liveWorker, MAX_ANSWERS, queueDir, resolveTicket } from '../worker/queue.mjs';
|
|
73
73
|
import { assertBlockId, assertInsideRoot, assertTicketId, contentHash, mainRootOf, normalizeRequestPath, repoRootOf, WorkerError } from '../worker/ticket.mjs';
|
|
74
74
|
import { blockFileSet, namedOnHash } from '../review/gate-check.mjs';
|
|
75
75
|
import { STOP_EXITS } from '../review/stop-exits.mjs';
|
|
@@ -391,21 +391,22 @@ async function waitBlockScope(repoRoot, cwd, run, block) {
|
|
|
391
391
|
}
|
|
392
392
|
|
|
393
393
|
/** A block wait reads only the marker, never review results or the signing key.
|
|
394
|
-
* @param {string} repoRoot @param {string} ticket @returns {
|
|
394
|
+
* @param {string} repoRoot @param {string} ticket @returns {Record<string, any> | null}
|
|
395
395
|
*/
|
|
396
|
-
function
|
|
396
|
+
function blockTicketMarker(repoRoot, ticket) {
|
|
397
397
|
try {
|
|
398
398
|
const marker = JSON.parse(readFileSync(path.join(queueDir(repoRoot), `${ticket}.done`), 'utf8'));
|
|
399
|
-
if (marker?.ticket === ticket && typeof marker.status === 'string' && marker.status.length > 0) return
|
|
399
|
+
if (marker?.ticket === ticket && typeof marker.status === 'string' && marker.status.length > 0) return marker;
|
|
400
400
|
} catch (err) {
|
|
401
|
-
if (err.code === 'ENOENT') return
|
|
401
|
+
if (err.code === 'ENOENT') return null;
|
|
402
402
|
}
|
|
403
403
|
throw new WorkerError('result-unreadable', 'a ticket completion marker cannot be read');
|
|
404
404
|
}
|
|
405
405
|
|
|
406
406
|
/**
|
|
407
|
-
*
|
|
408
|
-
*
|
|
407
|
+
* A block wait reads only completion/delivery markers, never review results or the signing key.
|
|
408
|
+
* Re-scan current content on every poll. Deliver unseen findings of the newest earlier ticket
|
|
409
|
+
* before rechecking a blind edit; the next join queues the fix. Stopped files stay finished.
|
|
409
410
|
* @param {{repoRoot: string, cwd: string, block: string, run: string, maxMs: number, pollMs: number, say: (obj: Record<string, any>) => unknown}} opts
|
|
410
411
|
*/
|
|
411
412
|
async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say }) {
|
|
@@ -413,6 +414,7 @@ async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say }) {
|
|
|
413
414
|
const where = realpathSync(tree) === realpathSync(repoRoot) ? {} : { tree: realpathSync(tree) };
|
|
414
415
|
const deadline = Date.now() + maxMs;
|
|
415
416
|
let downPolls = 0;
|
|
417
|
+
const deliveredHere = new Set();
|
|
416
418
|
for (;;) {
|
|
417
419
|
const worker = liveWorker(repoRoot);
|
|
418
420
|
const live = worker?.run === run;
|
|
@@ -424,17 +426,28 @@ async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say }) {
|
|
|
424
426
|
const { ticket } = resolveTicket(opts);
|
|
425
427
|
const previous = tickets.filter((t) => t.file === file);
|
|
426
428
|
let present = previous.find((t) => t.ticket === ticket);
|
|
429
|
+
if (!present && previous.length > 0) {
|
|
430
|
+
const newest = previous[previous.length - 1];
|
|
431
|
+
const marker = blockTicketMarker(repoRoot, newest.ticket);
|
|
432
|
+
if (marker?.status === 'stopped') return { file, ticket: newest.ticket, status: 'done' };
|
|
433
|
+
if (marker?.status === 'reviewed' && marker.open_findings === true && live) {
|
|
434
|
+
if (deliveredHere.has(newest.ticket) || deliverBlockFindings({ repoRoot, block, file, ticket: newest.ticket })) {
|
|
435
|
+
deliveredHere.add(newest.ticket);
|
|
436
|
+
return { file, ticket: newest.ticket, status: 'findings' };
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
427
440
|
if (!present && previous.length > 0 && live) {
|
|
428
441
|
enqueue(opts);
|
|
429
442
|
present = { ...previous[previous.length - 1], ticket };
|
|
430
443
|
}
|
|
431
|
-
return { file, ticket: present ? ticket : null, status: !present ? 'not_queued' :
|
|
444
|
+
return { file, ticket: present ? ticket : null, status: !present ? 'not_queued' : blockTicketMarker(repoRoot, ticket) ? 'done' : 'pending' };
|
|
432
445
|
});
|
|
433
446
|
if (downPolls >= 2) {
|
|
434
447
|
say({ block, files, done: false, status: 'worker_down' });
|
|
435
448
|
return 3;
|
|
436
449
|
}
|
|
437
|
-
if (live && files.every((f) => f.status === 'done')) {
|
|
450
|
+
if (live && files.every((f) => f.status === 'done' || f.status === 'findings')) {
|
|
438
451
|
say({ block, files, done: true });
|
|
439
452
|
return 0;
|
|
440
453
|
}
|
package/src/cli/review.mjs
CHANGED
|
@@ -48,7 +48,7 @@ import { semaphore } from '../util/locks.mjs';
|
|
|
48
48
|
import { writeSafe } from '../util/redact.mjs';
|
|
49
49
|
import { isAlive, listEntries, readStartTime, UNKNOWN_START_TIME } from '../util/reaper.mjs';
|
|
50
50
|
import { pidsDir, runRoot, setRunRoot, tmpBase } from '../util/tmp.mjs';
|
|
51
|
-
import {
|
|
51
|
+
import { liveWorker } from '../worker/queue.mjs';
|
|
52
52
|
import { gitChildEnv, normalizeRequestPath, repoRootOf, WorkerError } from '../worker/ticket.mjs';
|
|
53
53
|
|
|
54
54
|
const BIN = fileURLToPath(new URL('../../bin/code-forge.mjs', import.meta.url));
|
|
@@ -288,7 +288,7 @@ async function runWorkers(repoRoot, runId) {
|
|
|
288
288
|
} catch {
|
|
289
289
|
// no record: nothing pinned
|
|
290
290
|
}
|
|
291
|
-
const w =
|
|
291
|
+
const w = liveWorker(repoRoot);
|
|
292
292
|
if (w?.run === runId && !found.some((f) => f.pid === w.pid)) {
|
|
293
293
|
found.push({ pid: w.pid, same: async () => w.start_time === UNKNOWN_START_TIME || readStartTime(w.pid) === w.start_time });
|
|
294
294
|
}
|
package/src/context/pack.mjs
CHANGED
|
@@ -59,7 +59,7 @@ export const DIGEST_LESSONS_HEADING = '### lessons';
|
|
|
59
59
|
* A refusal with a stable `code`. Thrown here: `bad-context` (a bad `context` value),
|
|
60
60
|
* `control-character` (in a path or a header value), `outside`, `missing` (ENOENT), `unreadable`
|
|
61
61
|
* (any other lstat or read error, with its code), `not-file`. Thrown by `src/cli/context.mjs`:
|
|
62
|
-
* `no-block`, `bad-config`, `no-
|
|
62
|
+
* `no-block`, `bad-config`, `no-run-root`, `write-failed`.
|
|
63
63
|
*/
|
|
64
64
|
export class ContextError extends Error {
|
|
65
65
|
/** @param {string} code @param {string} message */
|
|
@@ -216,11 +216,12 @@ function readLines(repoRoot, rel, key) {
|
|
|
216
216
|
|
|
217
217
|
/**
|
|
218
218
|
* Every rules file and the lessons file, read. A missing or outside file is a refusal.
|
|
219
|
-
* @param {string} repoRoot @param {ContextSettings} settings
|
|
219
|
+
* @param {string} repoRoot @param {ContextSettings | null} settings
|
|
220
220
|
* @returns {{rules: Array<{path: string, lines: string[]}>, lessons: {path: string, lines: string[]} | null}}
|
|
221
221
|
* @throws {ContextError}
|
|
222
222
|
*/
|
|
223
223
|
export function readSources(repoRoot, settings) {
|
|
224
|
+
if (settings === null) return { rules: [], lessons: null };
|
|
224
225
|
const rules = settings.rules.map((rel) => ({ path: rel, lines: readLines(repoRoot, rel, 'context.rules') }));
|
|
225
226
|
const lessons = settings.lessons === null ? null : { path: settings.lessons, lines: readLines(repoRoot, settings.lessons, 'context.lessons') };
|
|
226
227
|
return { rules, lessons };
|
|
@@ -249,7 +250,8 @@ export const argvText = (argv) => argv.map(argvToken).join(' ');
|
|
|
249
250
|
|
|
250
251
|
/**
|
|
251
252
|
* Build the pack text for a block (pure over what it is given).
|
|
252
|
-
*
|
|
253
|
+
* Missing settings give a header and proof rule only, with the default line cap.
|
|
254
|
+
* @param {{settings: ContextSettings | null, sources: ReturnType<typeof readSources>, block: PackBlock}} opts
|
|
253
255
|
* @returns {{text: string, lines: number, cut: number}} `cut`: the lines dropped.
|
|
254
256
|
* @throws {ContextError} `control-character` when a header value holds one.
|
|
255
257
|
*/
|
|
@@ -267,11 +269,13 @@ export function assemblePack({ settings, sources, block }) {
|
|
|
267
269
|
for (const f of block.tests) header.push(`- ${f}`);
|
|
268
270
|
}
|
|
269
271
|
if (block.worktree) header.push(`worktree: ${block.worktree.path}`, `branch: ${block.worktree.branch}`);
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
272
|
+
if (settings !== null) {
|
|
273
|
+
header.push('## commands (never run by code-forge)');
|
|
274
|
+
const keys = COMMAND_KEYS.filter((k) => Object.hasOwn(settings.commands, k));
|
|
275
|
+
if (keys.length === 0) header.push('(none)');
|
|
276
|
+
// a command line can name a secret (a token in an argv): it is redacted as rules and lessons are
|
|
277
|
+
for (const k of keys) header.push(`${k}: ${redact(argvText(settings.commands[k]))}`);
|
|
278
|
+
}
|
|
275
279
|
|
|
276
280
|
header.push(...splitLines(PROOF_RULE));
|
|
277
281
|
|
|
@@ -282,7 +286,7 @@ export function assemblePack({ settings, sources, block }) {
|
|
|
282
286
|
const total = () => header.length + sections.reduce((n, s) => n + 1 + s.body.length + (s.dropped > 0 ? 1 : 0), 0);
|
|
283
287
|
// cut order: the lessons first, then the rules from the last file backwards
|
|
284
288
|
for (const s of [...sections].reverse()) {
|
|
285
|
-
let excess = total() - settings
|
|
289
|
+
let excess = total() - (settings?.maxLines ?? DEFAULT_MAX_LINES);
|
|
286
290
|
if (excess <= 0) break;
|
|
287
291
|
// the marker takes one line: a section is only cut when that leaves it shorter
|
|
288
292
|
if (s.body.length < 2) continue;
|
package/src/doctor/index.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import path from 'node:path';
|
|
|
13
13
|
import { cliNameForProvider } from '../engines/provider-cli.mjs';
|
|
14
14
|
import { newRunId } from '../state/paths.mjs';
|
|
15
15
|
import { runRoot } from '../util/tmp.mjs';
|
|
16
|
-
import { checkCommands, checkConfig, checkJev, checkKeys, checkLedger, checkLinks, checkReviewConcurrency, checkReviewState, checkTmp, checkTools } from './local.mjs';
|
|
16
|
+
import { checkCommands, checkConfig, checkJev, checkKeys, checkLedger, checkLinks, checkLocks, checkReviewConcurrency, checkReviewState, checkTmp, checkTools } from './local.mjs';
|
|
17
17
|
import { checkCoderSandbox, configuredProviders, pingRoles, probeCli, probeCodexRules, probeIsolation, probePathDeny } from './probes.mjs';
|
|
18
18
|
import { row } from './rows.mjs';
|
|
19
19
|
import { probeWorkerAndSigner, SIGNER_ROW } from './worker-probe.mjs';
|
|
@@ -67,6 +67,7 @@ export async function runDoctor(opts = {}, deps = {}) {
|
|
|
67
67
|
};
|
|
68
68
|
rows.push(...(await checkTools(toolOpts)));
|
|
69
69
|
rows.push(...(await checkLedger()));
|
|
70
|
+
rows.push(...(await checkLocks()));
|
|
70
71
|
rows.push(...checkTmp(cfg));
|
|
71
72
|
rows.push(...(await checkReviewState(cfg)));
|
|
72
73
|
rows.push(checkReviewConcurrency(cfg));
|
package/src/doctor/local.mjs
CHANGED
|
@@ -25,13 +25,32 @@ import { assertRunId, runsDir } from '../state/paths.mjs';
|
|
|
25
25
|
import { readRun } from '../state/run.mjs';
|
|
26
26
|
import { loadKey } from '../state/signer.mjs';
|
|
27
27
|
import { listEntries } from '../util/reaper.mjs';
|
|
28
|
+
import { listBadLocks } from '../util/file-locks.mjs';
|
|
28
29
|
import { ownerState, runRootPath, tmpBase } from '../util/tmp.mjs';
|
|
29
30
|
import { parallelTickets } from '../worker/loop.mjs';
|
|
31
|
+
import { workerRegistry } from '../worker/queue.mjs';
|
|
32
|
+
import { mainRootOf } from '../worker/ticket.mjs';
|
|
30
33
|
import { missingStateFiles } from '../worker/review-state.mjs';
|
|
31
34
|
import { row } from './rows.mjs';
|
|
32
35
|
|
|
33
36
|
/** @typedef {import('./rows.mjs').Row} Row */
|
|
34
37
|
|
|
38
|
+
/** @param {string} [cwd] @returns {Promise<Row[]>} */
|
|
39
|
+
export async function checkLocks(cwd = process.cwd()) {
|
|
40
|
+
try {
|
|
41
|
+
const paths = listBadLocks();
|
|
42
|
+
const rows = paths.length === 0
|
|
43
|
+
? [row('locks', 'OK', 'locks', 'no unreadable or malformed locks')]
|
|
44
|
+
: paths.map((file) => row('locks', 'WARN', 'locks', file));
|
|
45
|
+
let repoRoot = cwd;
|
|
46
|
+
try { repoRoot = await mainRootOf(cwd); } catch { /* non-repository doctor */ }
|
|
47
|
+
rows.push(...workerRegistry(repoRoot).filter((entry) => entry.worker === null).map((entry) => row('worker-registry', 'WARN', 'worker registry', entry.file)));
|
|
48
|
+
return rows;
|
|
49
|
+
} catch (err) {
|
|
50
|
+
return [row('locks', 'WARN', 'locks', `could not list locks (${err?.code ?? 'error'})`)];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
35
54
|
/** The ids of the `--quick` rows, in print order. */
|
|
36
55
|
export const QUICK_IDS = Object.freeze(['config', 'links', 'keys']);
|
|
37
56
|
|
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
* MAC is tamper evidence within ordinary tool use, never a boundary against a same-user process.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { copyFileSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
|
|
14
|
+
import { copyFileSync, mkdirSync, readdirSync, rmSync, symlinkSync } from 'node:fs';
|
|
15
15
|
import path from 'node:path';
|
|
16
|
+
import { commandOnPath } from '../install/detect.mjs';
|
|
16
17
|
import { runsDir } from '../state/paths.mjs';
|
|
17
18
|
import { startRun } from '../state/run.mjs';
|
|
18
19
|
import { loadKey, signRow, verifyRow } from '../state/signer.mjs';
|
|
@@ -94,7 +95,19 @@ export async function probeWorkerAndSigner(ctx) {
|
|
|
94
95
|
const signerOk = verifyRow(signed, key).ok && verifyRow(flipped, key).reason === 'mismatch';
|
|
95
96
|
rows.push(row('signer-key', signerOk ? 'OK' : 'FAIL', 'signer key', signerOk ? 'generated; a signed row verifies, a changed one is refused' : 'sign/verify round trip failed'));
|
|
96
97
|
|
|
97
|
-
|
|
98
|
+
// The boot lock needs ps even with a restricted provider PATH. Expose just that utility.
|
|
99
|
+
let workerEnv = env;
|
|
100
|
+
if (!await commandOnPath('ps', { pathEnv: env.PATH ?? '' })) {
|
|
101
|
+
const tools = path.join(repo, 'worker-tools');
|
|
102
|
+
for (const systemDir of ['/bin', '/usr/bin']) {
|
|
103
|
+
if (!await commandOnPath('ps', { pathEnv: systemDir })) continue;
|
|
104
|
+
mkdirSync(tools, { mode: 0o700 });
|
|
105
|
+
symlinkSync(path.join(systemDir, 'ps'), path.join(tools, 'ps'));
|
|
106
|
+
workerEnv = { ...env, PATH: [env.PATH, tools].filter(Boolean).join(path.delimiter) };
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const started = await launchWorker({ runId, workspace: repo, env: workerEnv });
|
|
98
111
|
pid = started.pid;
|
|
99
112
|
registerPid(pidsDir(ctx.runRootDir), pid, 'code-forge worker'); // a killed doctor's worker is reaped by the next sweep
|
|
100
113
|
const announced = liveWorker(started.repoRoot)?.pid === pid;
|
package/src/ledger/spend.mjs
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { logWarning } from '../util/error-log.mjs';
|
|
15
|
-
import {
|
|
15
|
+
import { withLock } from '../util/file-locks.mjs';
|
|
16
16
|
import { writeSafe } from '../util/redact.mjs';
|
|
17
17
|
import { estimateCostUsd } from './prices.mjs';
|
|
18
18
|
|
|
@@ -199,7 +199,7 @@ export const SPEND_UNREADABLE_MESSAGE = "budget.usd is set but the run's spend c
|
|
|
199
199
|
* Several sessions of one run can start at once (B40 slots, B42 ticket pool). The ledger only
|
|
200
200
|
* learns a session's cost when its row is written, so a plain "spent < budget" check lets every
|
|
201
201
|
* session that starts before the first row lands go through. Instead the check AND a reservation
|
|
202
|
-
* of the session's estimated cost happen under ONE
|
|
202
|
+
* of the session's estimated cost happen under ONE cross-process lock per run (`withBudgetLock`):
|
|
203
203
|
*
|
|
204
204
|
* spent ≥ budget, or spent + reserved + estimate > budget ⇒ refused (the B33 message, unchanged)
|
|
205
205
|
*
|
|
@@ -275,7 +275,7 @@ export function reserveBudget(run, usd) {
|
|
|
275
275
|
*/
|
|
276
276
|
export function withBudgetLock(run, fn) {
|
|
277
277
|
lockCalls += 1;
|
|
278
|
-
return
|
|
278
|
+
return withLock(`budget:${run}`, fn);
|
|
279
279
|
}
|
|
280
280
|
|
|
281
281
|
/** Test spy: how many times {@link withBudgetLock} was entered since the last reset. */
|
package/src/proof/lock.mjs
CHANGED
|
@@ -150,7 +150,11 @@ export async function runGatesGuarded({ runId, blockId, gates, cwd, timeoutMs, t
|
|
|
150
150
|
blockId,
|
|
151
151
|
fn: async () => {
|
|
152
152
|
// Plain gates without --changed need no scope or baseline.
|
|
153
|
-
if (!hasScopeItem(gates) && !changed)
|
|
153
|
+
if (!hasScopeItem(gates) && !changed) {
|
|
154
|
+
const record = await readRun(runId);
|
|
155
|
+
const ran = await runGates(gates, { cwd, ...(timeoutMs ? { timeoutMs } : {}) });
|
|
156
|
+
return { ...ran, ledger_slug: slug ?? record.project };
|
|
157
|
+
}
|
|
154
158
|
// the slot checked the block is open; it may still be gone by the time it is read again
|
|
155
159
|
const record = await readRun(runId);
|
|
156
160
|
const block = record.blocks?.[blockId];
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* session really reviewed these bytes; a repeated stop or an `unavailable` result never counts),
|
|
17
17
|
* the latest refusal row for the hash is signed, carries `content_hash` (a hash-less one does
|
|
18
18
|
* not qualify, though it still refuses) and names at least one finding, and every finding it
|
|
19
|
-
* names and every open id of the file's `review.cap` rows is cleared by a waiver the gate
|
|
19
|
+
* names and every open id of the file's unsuperseded `review.cap` rows is cleared by a waiver the gate
|
|
20
20
|
* accepts (the same rule as below: a critical only by `by: human`). Such a file is never
|
|
21
21
|
* approved; the close writes one signed `gate.review_waived_all` row for it ({@link reviewGateCheck});
|
|
22
22
|
* - `unsigned`: the file's approvals for that hash exist but none carries a valid MAC;
|
|
@@ -43,7 +43,8 @@
|
|
|
43
43
|
* - `review_cap`: a file stopped at `review_cap` has an open finding with no signed
|
|
44
44
|
* `review.waived {by: human}` row — unless a signed approval of the file's current hash comes
|
|
45
45
|
* AFTER the cap row (ledger order) and no later refusal row made it stale (the human fixed it
|
|
46
|
-
* by hand and it was reviewed)
|
|
46
|
+
* by hand and it was reviewed), or a verified human reopen after the cap is followed by a
|
|
47
|
+
* signed review round of the current bytes; a finding already refused as `not_approved` for that file is
|
|
47
48
|
* not repeated. B73: this detail, and an `unreviewed` detail for a file with a cap row, ends
|
|
48
49
|
* with the stop's ways out (`exits: block reopen, block waive, review-file --answers,
|
|
49
50
|
* re-decompose`, `stop-exits.mjs`);
|
|
@@ -137,7 +138,7 @@ export function checkBlockReviews({ block, runId, files, rows, key, transcript =
|
|
|
137
138
|
/** @type {Map<string, string>} */
|
|
138
139
|
const current = new Map();
|
|
139
140
|
for (const f of [...proofFiles, ...files]) current.set(f.file, f.content_hash);
|
|
140
|
-
const { mine, latestOf, signedOf, matches, waiverOf, waiverFor, waivedAllOf, recordedRefusalSeverity } = reviewRules({ block, runId, rows, key, resultFiles, current });
|
|
141
|
+
const { mine, latestOf, signedOf, matches, waiverOf, waiverFor, waivedAllOf, recordedRefusalSeverity, supersededCap } = reviewRules({ block, runId, rows, key, resultFiles, current });
|
|
141
142
|
/** @type {Refusal[]} */
|
|
142
143
|
const refusals = [];
|
|
143
144
|
const approvedFor = (/** @type {string} */ file, /** @type {string} */ hash) => mine.filter((r) => r.event === 'review.approved' && r.file === file && r.content_hash === hash);
|
|
@@ -216,6 +217,7 @@ export function checkBlockReviews({ block, runId, files, rows, key, transcript =
|
|
|
216
217
|
}
|
|
217
218
|
|
|
218
219
|
for (const cap of mine.filter((r) => r.event === 'review.cap')) {
|
|
220
|
+
if (supersededCap(cap)) continue;
|
|
219
221
|
const hash = hashOf.get(cap.file);
|
|
220
222
|
// honoured unless a signed approval of the current content comes AFTER the cap and is not stale
|
|
221
223
|
if (hash !== undefined) {
|
|
@@ -260,6 +262,16 @@ function reviewRules({ block, runId, rows, key, resultFiles = {}, current, verif
|
|
|
260
262
|
// namedOnHash}), which has no key and can only refuse; it then reads no autopilot waiver
|
|
261
263
|
const ok = verify ?? ((/** @type {Record<string, any>} */ r) => verified(r, key));
|
|
262
264
|
const mine = rows.filter((r) => r?.block === block && (runId === undefined || r.run === undefined || r.run === runId));
|
|
265
|
+
/** A human reopen followed by a real review of the current bytes clears older cap ids.
|
|
266
|
+
* @param {Record<string, any>} cap @returns {boolean}
|
|
267
|
+
*/
|
|
268
|
+
const supersededCap = (cap) => {
|
|
269
|
+
const hash = current.get(cap.file);
|
|
270
|
+
if (hash === undefined) return false;
|
|
271
|
+
const at = mine.indexOf(cap);
|
|
272
|
+
return mine.some((r, i) => i > at && r.event === 'review.reopened' && r.run === runId && r.file === cap.file && r.by === 'human' && ok(r)
|
|
273
|
+
&& mine.some((round, j) => j > i && round.event === 'review.round' && round.run === runId && round.file === cap.file && round.content_hash === hash && ok(round)));
|
|
274
|
+
};
|
|
263
275
|
/**
|
|
264
276
|
* B52: the index of the last signed approval and the latest refusal AFTER it for (file, hash);
|
|
265
277
|
* `denial` is null when that approval stands (or there is none — `unreviewed` / `unsigned` say
|
|
@@ -380,7 +392,7 @@ function reviewRules({ block, runId, rows, key, resultFiles = {}, current, verif
|
|
|
380
392
|
};
|
|
381
393
|
|
|
382
394
|
/**
|
|
383
|
-
* B79: the ids every signed `review.cap` row for (file, hash) names. A cap row records no
|
|
395
|
+
* B79: the ids every unsuperseded signed `review.cap` row for (file, hash) names. A cap row records no
|
|
384
396
|
* severity, so the rules read each of these ids at {@link recordedRefusalSeverity} — also when
|
|
385
397
|
* that cap row is the latest refusal (its own null never stands for them).
|
|
386
398
|
* @param {string} file @param {string} hash @returns {Set<string>}
|
|
@@ -390,6 +402,7 @@ function reviewRules({ block, runId, rows, key, resultFiles = {}, current, verif
|
|
|
390
402
|
const ids = new Set();
|
|
391
403
|
for (const cap of mine) {
|
|
392
404
|
if (cap.event !== 'review.cap' || cap.file !== file || cap.content_hash !== hash || !Array.isArray(cap.open) || !ok(cap)) continue;
|
|
405
|
+
if (supersededCap(cap)) continue;
|
|
393
406
|
for (const id of cap.open) if (typeof id === 'string' && id.length > 0) ids.add(id);
|
|
394
407
|
}
|
|
395
408
|
return ids;
|
|
@@ -400,7 +413,7 @@ function reviewRules({ block, runId, rows, key, resultFiles = {}, current, verif
|
|
|
400
413
|
* or null when no such review qualifies. All of: a signed `review.round` row for (file, hash) — a
|
|
401
414
|
* session reviewed these bytes; the latest refusal row for (file, hash) is signed, carries
|
|
402
415
|
* `content_hash: hash` (a hash-less one never qualifies) and names at least one finding. The ids
|
|
403
|
-
* are every finding it names and every open id of the file's `review.cap` rows. Severities: an id
|
|
416
|
+
* are every finding it names and every open id of the file's unsuperseded `review.cap` rows. Severities: an id
|
|
404
417
|
* a signed `review.cap` row at (file, hash) names ({@link capIdsAt}) — also when that cap row is
|
|
405
418
|
* the latest refusal — and any other open cap id use {@link recordedRefusalSeverity}, whose
|
|
406
419
|
* `unknown` (no signed refusal at `hash` records one) is the human's alone and never answered.
|
|
@@ -417,6 +430,7 @@ function reviewRules({ block, runId, rows, key, resultFiles = {}, current, verif
|
|
|
417
430
|
const named = new Map(latest.open.map((f) => [f.id, f.severity]));
|
|
418
431
|
for (const id of capIdsAt(file, hash)) named.set(id, recordedRefusalSeverity(file, hash, id));
|
|
419
432
|
for (const cap of mine.filter((r) => r.event === 'review.cap' && r.file === file)) {
|
|
433
|
+
if (supersededCap(cap)) continue;
|
|
420
434
|
if (!Array.isArray(cap.open) || !cap.open.every((/** @type {unknown} */ id) => typeof id === 'string' && id.length > 0)) return null;
|
|
421
435
|
for (const id of cap.open) if (!named.has(id)) named.set(id, recordedRefusalSeverity(file, hash, id));
|
|
422
436
|
}
|
|
@@ -477,7 +491,7 @@ function reviewRules({ block, runId, rows, key, resultFiles = {}, current, verif
|
|
|
477
491
|
return new Map([...named].filter(([id, severity]) => id !== PROOF_FINDING && answerableSeverity(severity) && waiverOf(file, id, severity, ticket).by === null));
|
|
478
492
|
};
|
|
479
493
|
|
|
480
|
-
return { mine, latestOf, signedOf, matches, waiverOf, waiverFor, waivedAllOf, openAt, answerRows, recordedRefusalSeverity };
|
|
494
|
+
return { mine, latestOf, signedOf, matches, waiverOf, waiverFor, waivedAllOf, openAt, answerRows, recordedRefusalSeverity, supersededCap };
|
|
481
495
|
}
|
|
482
496
|
|
|
483
497
|
/**
|
|
@@ -1045,7 +1059,18 @@ export function reviewGateCheck(load, { onWaivedAll } = {}) {
|
|
|
1045
1059
|
onWaivedAll?.(result.waivedAll);
|
|
1046
1060
|
return { ok: true };
|
|
1047
1061
|
}
|
|
1048
|
-
|
|
1062
|
+
const reasons = [];
|
|
1063
|
+
const transcriptIds = new Set();
|
|
1064
|
+
for (const r of result.refusals) {
|
|
1065
|
+
const hit = r.code === 'rule_break' && /^transcript line \d+: (.+)$/.exec(r.detail);
|
|
1066
|
+
if (hit) {
|
|
1067
|
+
if (transcriptIds.has(hit[1])) continue;
|
|
1068
|
+
transcriptIds.add(hit[1]);
|
|
1069
|
+
if (transcriptIds.size <= 5) reasons.push(`rule_break ${r.detail}`);
|
|
1070
|
+
} else reasons.push(`${r.code}${r.file ? ` ${r.file}` : ''}${r.finding ? ` ${r.finding}` : ''}`);
|
|
1071
|
+
}
|
|
1072
|
+
if (transcriptIds.size > 5) reasons.push(`+${transcriptIds.size - 5} more rule_break`);
|
|
1073
|
+
return { ok: false, reason: reasons.join('; ') };
|
|
1049
1074
|
};
|
|
1050
1075
|
}
|
|
1051
1076
|
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
* Concurrency: the worker never runs two tickets for one (block, file) at once (its file lock,
|
|
60
60
|
* `worker/loop.mjs`), and round 1, its retry, every recheck and the patch_check of a file are all
|
|
61
61
|
* tickets for that file, so one packet's history is read and written by one ticket at a time. The
|
|
62
|
-
* read-history → write-mark section here also runs under
|
|
62
|
+
* read-history → write-mark section here also runs under a cross-process lock per (packet hash,
|
|
63
63
|
* level, role), so even the same file in two blocks cannot take a packet's one try twice.
|
|
64
64
|
*
|
|
65
65
|
* No ledger writer (`writeRow` absent) ⇒ nothing is logged and nothing is tried. The helpers never
|
|
@@ -71,7 +71,7 @@ import { createHash } from 'node:crypto';
|
|
|
71
71
|
import { closedBookRefused, openBookCodexAllowed } from '../config/closed-book.mjs';
|
|
72
72
|
import { resolveLevel } from '../config/known-ids.mjs';
|
|
73
73
|
import { logWarning } from '../util/error-log.mjs';
|
|
74
|
-
import {
|
|
74
|
+
import { withLock } from '../util/file-locks.mjs';
|
|
75
75
|
import { schemaErrorsOf } from './validate-review.mjs';
|
|
76
76
|
|
|
77
77
|
/** Broken results (B91: schema misses and timeouts) of the level's own ladder on one packet before the second level is tried. */
|
|
@@ -291,7 +291,7 @@ export async function afterSchemaMiss({ res, session, packetText, cfg, allowed,
|
|
|
291
291
|
/** @type {{cfg: Record<string, any>, to: {level: string, provider: string, model: string}} | null} */
|
|
292
292
|
let go = null;
|
|
293
293
|
try {
|
|
294
|
-
go = await
|
|
294
|
+
go = await withLock(`schema-fallback\0${hash}\0${session.level}\0${session.role}`, async () => {
|
|
295
295
|
// the history is read BEFORE a schema miss is logged: failures = signed earlier misses + this
|
|
296
296
|
// one. A timeout's note is already in it (B91): it is the miss in hand, never added twice.
|
|
297
297
|
let seen = null;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/** Cross-process locks, with FIFO serialization inside this process. */
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { closeSync, mkdirSync, openSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { processStartTime } from '../state/run.mjs';
|
|
7
|
+
import { keyedLock } from './locks.mjs';
|
|
8
|
+
|
|
9
|
+
/** @typedef {{probe?: typeof processStartTime, alive?: (pid: number) => boolean, afterRead?: () => Promise<unknown>, pollMs?: number}} LockOpts */
|
|
10
|
+
/** @typedef {{ok: true, release: () => void} | {ok: false, result: 'held'}} LockResult */
|
|
11
|
+
/** @type {{ok: false, result: 'held'}} */
|
|
12
|
+
const HELD = { ok: false, result: 'held' };
|
|
13
|
+
const locksDir = () => path.join(os.homedir(), '.code-forge', 'locks');
|
|
14
|
+
/** @param {string} key */
|
|
15
|
+
const lockPath = (key) => path.join(locksDir(), `${createHash('sha256').update(key).digest('hex')}.lock`);
|
|
16
|
+
/** @type {Promise<string | null> | undefined} */
|
|
17
|
+
let selfStart;
|
|
18
|
+
|
|
19
|
+
/** @param {number} pid */
|
|
20
|
+
function alive(pid) {
|
|
21
|
+
try { process.kill(pid, 0); return true; }
|
|
22
|
+
catch (err) { return err.code !== 'ESRCH'; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** @param {Buffer} bytes @returns {{key: string, pid: number, start: string} | null} */
|
|
26
|
+
function ownerOf(bytes) {
|
|
27
|
+
try {
|
|
28
|
+
const owner = JSON.parse(bytes.toString('utf8'));
|
|
29
|
+
return owner && typeof owner.key === 'string' && Number.isInteger(owner.pid) && owner.pid > 1 && typeof owner.start === 'string' ? owner : null;
|
|
30
|
+
} catch { return null; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Exclusive creation; release synchronously removes only this owner's bytes.
|
|
34
|
+
* @param {string} file @param {Buffer} bytes @returns {LockResult}
|
|
35
|
+
*/
|
|
36
|
+
function create(file, bytes) {
|
|
37
|
+
let fd;
|
|
38
|
+
try { fd = openSync(file, 'wx', 0o600); }
|
|
39
|
+
catch (err) { if (err.code === 'EEXIST') return HELD; throw err; }
|
|
40
|
+
try { writeFileSync(fd, bytes); }
|
|
41
|
+
finally { closeSync(fd); }
|
|
42
|
+
let released = false;
|
|
43
|
+
return { ok: true, release() {
|
|
44
|
+
if (released) return;
|
|
45
|
+
released = true;
|
|
46
|
+
try { if (readFileSync(file).equals(bytes)) unlinkSync(file); }
|
|
47
|
+
catch { /* missing, unreadable or replaced: leave it alone */ }
|
|
48
|
+
} };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Take a key or fail closed. A proven dead owner permits one guarded break attempt.
|
|
52
|
+
* @param {string} key @param {LockOpts} [opts] @returns {Promise<LockResult>}
|
|
53
|
+
*/
|
|
54
|
+
export async function tryLock(key, opts = {}) {
|
|
55
|
+
const probe = opts.probe ?? processStartTime;
|
|
56
|
+
const start = await (selfStart ??= processStartTime(process.pid));
|
|
57
|
+
if (typeof start !== 'string') throw new Error('cannot determine lock owner process start time');
|
|
58
|
+
const file = lockPath(key);
|
|
59
|
+
const bytes = Buffer.from(JSON.stringify({ key, pid: process.pid, start }));
|
|
60
|
+
mkdirSync(locksDir(), { recursive: true, mode: 0o700 });
|
|
61
|
+
const initial = create(file, bytes);
|
|
62
|
+
if (initial.ok) return initial;
|
|
63
|
+
let seen;
|
|
64
|
+
try { seen = readFileSync(file); }
|
|
65
|
+
catch { return HELD; }
|
|
66
|
+
await opts.afterRead?.();
|
|
67
|
+
const owner = ownerOf(seen);
|
|
68
|
+
if (owner === null || owner.key !== key) return HELD;
|
|
69
|
+
let dead = !(opts.alive ?? alive)(owner.pid);
|
|
70
|
+
if (!dead) {
|
|
71
|
+
const actual = await probe(owner.pid);
|
|
72
|
+
dead = actual !== null && actual !== owner.start;
|
|
73
|
+
}
|
|
74
|
+
if (!dead) return HELD;
|
|
75
|
+
|
|
76
|
+
// Only unchanged dead-owner bytes may be removed under the per-key break mutex.
|
|
77
|
+
const mutex = `${file}.break`;
|
|
78
|
+
let guard;
|
|
79
|
+
try { guard = openSync(mutex, 'wx', 0o600); }
|
|
80
|
+
catch { return HELD; }
|
|
81
|
+
try {
|
|
82
|
+
writeFileSync(guard, bytes);
|
|
83
|
+
try { if (!readFileSync(file).equals(seen)) return HELD; }
|
|
84
|
+
catch { return HELD; }
|
|
85
|
+
unlinkSync(file);
|
|
86
|
+
return create(file, bytes);
|
|
87
|
+
} finally {
|
|
88
|
+
closeSync(guard);
|
|
89
|
+
unlinkSync(mutex);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** @template T @param {string} key @param {() => T | Promise<T>} fn @param {LockOpts} [opts] @returns {Promise<T>} */
|
|
94
|
+
export function withLock(key, fn, opts = {}) {
|
|
95
|
+
return keyedLock(key, async () => {
|
|
96
|
+
let lock = await tryLock(key, opts);
|
|
97
|
+
while (!lock.ok) {
|
|
98
|
+
await new Promise((resolve) => setTimeout(resolve, opts.pollMs ?? 25));
|
|
99
|
+
lock = await tryLock(key, opts);
|
|
100
|
+
}
|
|
101
|
+
try { return await fn(); }
|
|
102
|
+
finally { lock.release(); }
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** @returns {string[]} paths of unreadable/malformed locks or dead-owner break mutexes. */
|
|
107
|
+
export function listBadLocks() {
|
|
108
|
+
let names;
|
|
109
|
+
try { names = readdirSync(locksDir()); }
|
|
110
|
+
catch (err) { if (err.code === 'ENOENT') return []; throw err; }
|
|
111
|
+
return names.filter((name) => name.endsWith('.lock') || name.endsWith('.lock.break')).sort().map((name) => path.join(locksDir(), name)).filter((file) => {
|
|
112
|
+
try {
|
|
113
|
+
const owner = ownerOf(readFileSync(file));
|
|
114
|
+
return owner === null || (file.endsWith('.lock.break') && !alive(owner.pid));
|
|
115
|
+
}
|
|
116
|
+
catch { return true; }
|
|
117
|
+
});
|
|
118
|
+
}
|