@codedology/code-forge 0.15.1 → 0.15.3
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 +62 -0
- package/README.md +5 -1
- package/docs/README.md +2 -0
- package/docs/reference/blocks.json +154 -1
- package/docs/reference/config.md +27 -14
- package/package.json +1 -1
- package/schema/code-forge.schema.json +57 -2
- 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/code.md +15 -0
- package/skill/references/ledger.md +13 -1
- package/skill/scripts/forge +1 -1
- package/src/cli/block.mjs +146 -30
- package/src/cli/context.mjs +17 -7
- package/src/cli/gates.mjs +76 -10
- package/src/cli/help.mjs +4 -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 +168 -38
- package/src/cli/run.mjs +3 -2
- package/src/cli/sweep.mjs +28 -0
- package/src/cli/validate.mjs +6 -0
- package/src/config/schema-compile.mjs +6 -4
- package/src/config/validate.mjs +83 -1
- package/src/context/pack.mjs +53 -3
- package/src/doctor/local.mjs +22 -4
- 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 +18 -4
- package/src/review/engine.mjs +25 -5
- package/src/review/gate-check.mjs +39 -1
- package/src/review/lenses/full.md +1 -0
- package/src/review/lenses/quick.md +1 -0
- package/src/review/packet.mjs +34 -6
- package/src/review/related.mjs +112 -0
- package/src/review/reopen.mjs +4 -3
- package/src/review/session-retry.mjs +59 -0
- package/src/session/facts.mjs +18 -4
- package/src/session/spawn.mjs +54 -3
- package/src/state/block.mjs +101 -34
- 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/reaper.mjs +238 -8
- package/src/worker/engine.mjs +7 -3
- package/src/worker/loop.mjs +67 -14
- package/src/worker/queue.mjs +106 -23
- package/src/worker/watch.mjs +2 -2
package/src/state/block.mjs
CHANGED
|
@@ -24,9 +24,10 @@ import { isAncestorArgv, revParseArgv } from '../util/git.mjs';
|
|
|
24
24
|
import { StateError } from './paths.mjs';
|
|
25
25
|
import { assertOwned, blocksOverlap, isExactEntry, ownsFile, scopeGate } from './registry.mjs';
|
|
26
26
|
import { checkWorkerPin, readRun, saveRun, withRunLock, writeSigned } from './run.mjs';
|
|
27
|
-
import { canonicalJSON, verifyRow, loadKey } from './signer.mjs';
|
|
27
|
+
import { canonicalJSON, verifyRow, loadKey, signRow } from './signer.mjs';
|
|
28
|
+
import { readAllRows } from '../ledger/write.mjs';
|
|
28
29
|
import * as reviewQueue from '../worker/queue.mjs';
|
|
29
|
-
import { repoRootOf } from '../worker/ticket.mjs';
|
|
30
|
+
import { contentHash, repoRootOf } from '../worker/ticket.mjs';
|
|
30
31
|
|
|
31
32
|
/** @typedef {import('./run.mjs').WriteRow} WriteRow */
|
|
32
33
|
|
|
@@ -362,6 +363,11 @@ function refuseConfigOwner(owned, kinds, guarded) {
|
|
|
362
363
|
* B60: `tests` (`block open --tests <paths…>`, the same path rules as owned entries, else
|
|
363
364
|
* `bad-tests`) is recorded as the entry's `test_files` and on the dispatch row; a gate argv item
|
|
364
365
|
* `{test_files}` expands to them (`gates/expand.mjs`). Left out, neither carries the key.
|
|
366
|
+
* B148: `after` lists dependencies in this run. Under the same run lock, every dependency must
|
|
367
|
+
* be closed (`after-not-done`); self-dependency is `bad-after`. Unique ids are recorded on the
|
|
368
|
+
* entry and dispatch only when supplied, and a later attempt checks only its own supplied list.
|
|
369
|
+
* B153: `after_outcome` carries the specialist step ids and outcome byte hashes accepted by
|
|
370
|
+
* the CLI against the run's project root; it is recorded on the entry and dispatch when supplied.
|
|
365
371
|
* B62a: `worktree` (given by `block open` under `proof.isolation: worktree`, `proof/worktree.mjs`;
|
|
366
372
|
* this module never imports it) plans the block's own worktree, or the reuse of the one an earlier
|
|
367
373
|
* entry of the same id recorded (`previous`); its `make()` makes or reuses it. Both are called
|
|
@@ -376,9 +382,10 @@ function refuseConfigOwner(owned, kinds, guarded) {
|
|
|
376
382
|
* @param {{
|
|
377
383
|
* runId: string, id: string, level: string, owned: string[], acceptance: unknown,
|
|
378
384
|
* attempt?: number, base?: string, lines?: number, brief?: {path: string, content: Buffer | string},
|
|
379
|
-
* kind?: string, cfg?: Record<string, any>, writeRow: WriteRow, now?: Date, tests?: string[],
|
|
385
|
+
* kind?: string, cfg?: Record<string, any>, writeRow: WriteRow, now?: Date, tests?: string[], after?: string[],
|
|
386
|
+
* after_outcome?: {step: string, sha256: string}[],
|
|
380
387
|
* decision?: {question: 'lane', answer: string, source: 'rules', facts: Record<string, unknown>}, decisionId?: string,
|
|
381
|
-
* worktree?: (ctx: {baseSha: string, previous: TreeRecord | null}) => Promise<{path: string, make: () => Promise<OpenTree>}>,
|
|
388
|
+
* worktree?: (ctx: {baseSha: string, baseExplicit: boolean, previous: TreeRecord | null}) => Promise<{path: string, make: () => Promise<OpenTree>}>,
|
|
382
389
|
* }} opts
|
|
383
390
|
* @returns {Promise<{block: Record<string, any>, pointer: string | null, worktreeReused: boolean}>} `worktreeReused`:
|
|
384
391
|
* the block took an earlier attempt's worktree as it was.
|
|
@@ -395,6 +402,7 @@ export async function openBlock(opts) {
|
|
|
395
402
|
const acceptance = assertAcceptance(opts.acceptance);
|
|
396
403
|
const humanChecks = acceptance.filter((c) => c.kind === 'human_check').map((c) => c.clause);
|
|
397
404
|
const tests = opts.tests === undefined ? null : [...assertOwned(opts.tests, { label: 'test files', code: 'bad-tests' })];
|
|
405
|
+
const after = opts.after === undefined ? null : [...new Set(opts.after)];
|
|
398
406
|
const kind = opts.kind === 'light' ? 'light' : blockKind({ owned, declared: opts.kind });
|
|
399
407
|
const dispatched = dispatchLevel({ lane: level, kind, cfg: opts.cfg });
|
|
400
408
|
const pointer = opts.brief ? briefPointer(opts.brief.path, opts.brief.content) : null;
|
|
@@ -423,6 +431,14 @@ export async function openBlock(opts) {
|
|
|
423
431
|
const { block, reused } = await mutateRun(runId, null, writeRow, async (record) => {
|
|
424
432
|
if (record.blocks[id]?.status === 'open') throw new StateError('already-open', `block ${id} is already open`);
|
|
425
433
|
if (record.blocks[id]?.status === 'awaiting_human_check') throw new StateError('already-open', `block ${id} is awaiting a human check`);
|
|
434
|
+
if (after !== null) {
|
|
435
|
+
if (after.includes(id)) throw new StateError('bad-after', `block ${id} cannot name itself in --after`);
|
|
436
|
+
const blocking = after.filter((dependency) => record.blocks[dependency]?.status !== 'closed');
|
|
437
|
+
if (blocking.length > 0) {
|
|
438
|
+
const statuses = blocking.map((dependency) => `${dependency} (${record.blocks[dependency]?.status ?? 'not in run'})`);
|
|
439
|
+
throw new StateError('after-not-done', `block ${id}: --after dependencies not closed: ${statuses.join(', ')}`);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
426
442
|
// B57: what each exact owned entry is on disk, under the lock (as `block claim`) — a regular
|
|
427
443
|
// file owns only itself, here and in every later check
|
|
428
444
|
const kinds = ownedKinds(record.workspace, owned);
|
|
@@ -433,7 +449,7 @@ export async function openBlock(opts) {
|
|
|
433
449
|
const branch = await git(['git', 'symbolic-ref', '--quiet', '--short', 'HEAD'], record.workspace, [0, 1]);
|
|
434
450
|
const baseBranch = branch.code === 0 ? branch.stdout.trim() : null;
|
|
435
451
|
// B62a: the tree is planned (read-only checks) before the overlap check, which needs its path
|
|
436
|
-
const planned = opts.worktree ? await opts.worktree({ baseSha, previous: prior }) : null;
|
|
452
|
+
const planned = opts.worktree ? await opts.worktree({ baseSha, baseExplicit: opts.base !== undefined, previous: prior }) : null;
|
|
437
453
|
const self = { owned_files: owned, owned_kinds: kinds, worktree: planned ? { path: planned.path } : null };
|
|
438
454
|
for (const [otherId, other] of Object.entries(activeBlocks(record))) {
|
|
439
455
|
const pair = blocksOverlap(self, other);
|
|
@@ -443,8 +459,10 @@ export async function openBlock(opts) {
|
|
|
443
459
|
// passed; a tree made here is rolled back below if the rows or the save then fail
|
|
444
460
|
made = planned ? await planned.make() : null;
|
|
445
461
|
const tree = made;
|
|
446
|
-
const
|
|
462
|
+
const outcomeDependencies = opts.after_outcome === undefined ? {} : { after_outcome: opts.after_outcome };
|
|
463
|
+
const entry = { block: id, base_sha: baseSha, base_branch: baseBranch, owned_files: [...owned], owned_kinds: { ...kinds }, level: dispatched.level, kind, attempt, opened_at: now.toISOString(), acceptance, lines_forecast: lines, status: 'open', tree_at_open: treeOpen, ...(tests ? { test_files: tests } : {}), ...(after !== null ? { after } : {}), ...outcomeDependencies, ...(tree ? { worktree: tree.worktree } : {}) };
|
|
447
464
|
if (humanChecks.length > 0) Object.assign(entry, { human_checks: humanChecks });
|
|
465
|
+
if (opts.brief) Object.assign(entry, { brief: { path: opts.brief.path } });
|
|
448
466
|
record.blocks[id] = entry;
|
|
449
467
|
const floor = dispatched.trigger === 'docs_floor' ? { requested_level: level, trigger: 'docs_floor' } : {};
|
|
450
468
|
const created = tree?.created ? [{ event: 'worktree.created', block: id, path: tree.worktree.path, branch: tree.worktree.branch, base: tree.worktree.base_sha }] : [];
|
|
@@ -453,7 +471,7 @@ export async function openBlock(opts) {
|
|
|
453
471
|
rows: [
|
|
454
472
|
...(opts.decision ? [{ ...opts.decision, event: 'decision', decision_id: opts.decisionId ?? randomUUID(), block: id }] : []),
|
|
455
473
|
...created,
|
|
456
|
-
{ event: 'dispatch', block: id, level: dispatched.level, kind, ...floor, attempt, base_sha: baseSha, base_branch: baseBranch, owned_files: entry.owned_files, lines_forecast: lines, ...(tests ? { test_files: tests } : {}) },
|
|
474
|
+
{ event: 'dispatch', block: id, level: dispatched.level, kind, ...floor, attempt, base_sha: baseSha, base_branch: baseBranch, owned_files: entry.owned_files, lines_forecast: lines, ...(tests ? { test_files: tests } : {}), ...(after !== null ? { after } : {}), ...outcomeDependencies },
|
|
457
475
|
],
|
|
458
476
|
};
|
|
459
477
|
});
|
|
@@ -486,18 +504,19 @@ export async function attemptBlock({ runId, id, writeRow }) {
|
|
|
486
504
|
* in between touched a file this block owns (that is a conflict ⇒ stop). Rename detection is OFF
|
|
487
505
|
* (`--no-renames`): a rename lists both its old and its new path, so moving an owned file away
|
|
488
506
|
* is a conflict too. Never silent. Owned means `ownsFile` (B57: a directory entry owns the files
|
|
489
|
-
* below it).
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
*
|
|
493
|
-
* @
|
|
507
|
+
* below it). Worktree blocks rebase their branch onto the recorded base branch. Open blocks
|
|
508
|
+
* refuse upstream commits touching owned files; closed blocks retain every changed owned blob.
|
|
509
|
+
* A refusal restores the head at entry. A rebase check keeps the candidate content available
|
|
510
|
+
* to the worker, with a signed checkpoint; the recorded base moves only after clearance.
|
|
511
|
+
* @param {{runId: string, id: string, head?: string, writeRow: WriteRow, acceptChanged?: string[], reason?: string, rebaseCheck?: boolean}} opts
|
|
512
|
+
* @returns {Promise<{old_base: string, new_base: string, files?: ReturnType<typeof reviewQueue.enqueue>[], pending?: boolean}>}
|
|
494
513
|
*/
|
|
495
|
-
export async function rebaseBlock({ runId, id, head = 'HEAD', writeRow }) {
|
|
496
|
-
if (
|
|
514
|
+
export async function rebaseBlock({ runId, id, head = 'HEAD', writeRow, acceptChanged = [], reason, rebaseCheck = false }) {
|
|
515
|
+
if (acceptChanged.length && (typeof reason !== 'string' || !reason.trim())) throw new StateError('usage', '--accept-changed needs a nonblank --reason');
|
|
516
|
+
const block = (await readRun(runId)).blocks[id];
|
|
517
|
+
if (block?.worktree || ['closed', 'awaiting_human_check'].includes(block?.status)) return rebaseWorktreeBlock({ runId, id, writeRow, acceptChanged, reason, rebaseCheck });
|
|
518
|
+
if (rebaseCheck || acceptChanged.length) throw new StateError('usage', 'rebase clearance requires a worktree block');
|
|
497
519
|
return mutateRun(runId, id, writeRow, async (record, block) => {
|
|
498
|
-
// B62a: a worktree block's base is the commit its tree started from; moving it would leave the
|
|
499
|
-
// tree on its old base, so the rebase is refused
|
|
500
|
-
if (block.worktree) throw new StateError('worktree-rebase', `block ${id} has its own worktree ${block.worktree.path} on branch ${block.worktree.branch}; its base is the base of that tree, so block rebase does not apply — work on the branch in the tree instead`);
|
|
501
520
|
const cwd = record.workspace;
|
|
502
521
|
const newBase = await resolveCommit(head, cwd);
|
|
503
522
|
const ancestor = await git(isAncestorArgv(block.base_sha, newBase), cwd, [0, 1]);
|
|
@@ -507,7 +526,7 @@ export async function rebaseBlock({ runId, id, head = 'HEAD', writeRow }) {
|
|
|
507
526
|
if (conflicts.length > 0) throw new StateError('owned-conflict', `block ${id}: commits since its base changed owned files: ${conflicts.join(', ')}`);
|
|
508
527
|
const moved = { old_base: block.base_sha, new_base: newBase };
|
|
509
528
|
block.base_sha = newBase;
|
|
510
|
-
return { result: moved, rows: [{ event: 'block.rebase', block: id, ...moved }] };
|
|
529
|
+
return { result: moved, rows: [{ event: 'block.rebase', block: id, ...moved, changed_reviewed: [], cleared: [] }] };
|
|
511
530
|
});
|
|
512
531
|
}
|
|
513
532
|
|
|
@@ -530,44 +549,91 @@ async function committedBlobs(cwd, ref, files) {
|
|
|
530
549
|
}
|
|
531
550
|
|
|
532
551
|
/**
|
|
533
|
-
*
|
|
534
|
-
*
|
|
535
|
-
* @param {{runId: string, id: string, writeRow: WriteRow}} opts
|
|
536
|
-
* @returns {Promise<{old_base: string, new_base: string}>}
|
|
552
|
+
* Recheck worktree block status under the lock, restore the branch on failure (including a
|
|
553
|
+
* failed signed write), and retain its open/closed/awaiting status.
|
|
554
|
+
* @param {{runId: string, id: string, writeRow: WriteRow, acceptChanged: string[], reason?: string, rebaseCheck: boolean}} opts
|
|
555
|
+
* @returns {Promise<{old_base: string, new_base: string, files?: ReturnType<typeof reviewQueue.enqueue>[], pending?: boolean}>}
|
|
537
556
|
*/
|
|
538
|
-
async function
|
|
557
|
+
async function rebaseWorktreeBlock({ runId, id, writeRow, acceptChanged, reason, rebaseCheck }) {
|
|
539
558
|
return withRunLock(runId, async () => {
|
|
540
559
|
const record = await readRun(runId);
|
|
541
560
|
if (record.status !== 'active') throw new StateError('run-ended', `run ${runId} has ended`);
|
|
542
561
|
const block = record.blocks[id];
|
|
543
|
-
if (!['closed', 'awaiting_human_check'].includes(block?.status) || !block.worktree) throw new StateError('no-block', `block ${id} is not open in run ${runId}`);
|
|
562
|
+
if (!['open', 'closed', 'awaiting_human_check'].includes(block?.status) || !block.worktree) throw new StateError('no-block', `block ${id} is not open in run ${runId}`);
|
|
563
|
+
const closed = block.status !== 'open';
|
|
544
564
|
if (!block.base_branch) throw new StateError('rebase-no-base', `block ${id} has no recorded base branch`);
|
|
545
565
|
const tree = block.worktree.path;
|
|
546
566
|
const branch = await git(['git', 'symbolic-ref', '--quiet', 'HEAD'], tree, [0, 1]);
|
|
547
567
|
if (branch.stdout.trim() !== `refs/heads/${block.worktree.branch}`) throw new StateError('worktree-rebase', `block ${id}: its worktree is not on branch ${block.worktree.branch}`);
|
|
548
568
|
// A reset must never discard edits made after close, or abort an earlier rebase.
|
|
549
569
|
const status = await git(['git', 'status', '--porcelain'], tree);
|
|
550
|
-
if (status.stdout.length > 0) throw new StateError('rebase-conflict', 'rebase-conflict');
|
|
570
|
+
if (status.stdout.length > 0) throw new StateError(closed ? 'rebase-conflict' : 'rebase-dirty', closed ? 'rebase-conflict' : `rebase-dirty: block ${id}: commit or clean first`);
|
|
551
571
|
const oldHead = await resolveCommit('HEAD', tree);
|
|
572
|
+
const key = await loadKey(runId);
|
|
573
|
+
const pending = block.rebase_check;
|
|
574
|
+
if (pending && (!verifyRow(pending, key).ok || pending.run !== runId || pending.block !== id || pending.old_base !== block.base_sha)) {
|
|
575
|
+
throw new StateError('rebase-check-invalid', 'the pending rebase checkpoint is invalid');
|
|
576
|
+
}
|
|
577
|
+
if (pending && (await git(isAncestorArgv(pending.head, oldHead), tree, [0, 1])).code !== 0) {
|
|
578
|
+
throw new StateError('rebase-check-invalid', 'the worktree no longer contains the pending rebase');
|
|
579
|
+
}
|
|
580
|
+
const reviewedHead = pending?.old_head ?? oldHead;
|
|
552
581
|
const newBase = await resolveCommit(`refs/heads/${block.base_branch}`, record.workspace);
|
|
553
582
|
const top = await repoTop(record.workspace);
|
|
554
583
|
const prefix = path.relative(top, realpathSync(record.workspace)).split(path.sep).join('/');
|
|
555
|
-
const
|
|
556
|
-
const files = diff.stdout.split('\0').filter((file) => file.length > 0 && (
|
|
584
|
+
const owns = (/** @type {string} */ file) => (
|
|
557
585
|
ownsFile(block.owned_files, file, block.owned_kinds) ||
|
|
558
586
|
(prefix !== '' && file.startsWith(`${prefix}/`) && ownsFile(block.owned_files, file.slice(prefix.length + 1), block.owned_kinds))
|
|
559
|
-
)
|
|
560
|
-
|
|
587
|
+
);
|
|
588
|
+
let files = [];
|
|
589
|
+
if (closed) {
|
|
590
|
+
const diff = await git(['git', 'diff', '--no-renames', '--name-only', '-z', block.base_sha, reviewedHead, '--'], tree);
|
|
591
|
+
files = diff.stdout.split('\0').filter((file) => file.length > 0 && owns(file)).sort();
|
|
592
|
+
} else {
|
|
593
|
+
// Inspect every upstream commit, including changes later reverted and both rename paths.
|
|
594
|
+
const upstream = await git(['git', 'log', '--format=', '--no-renames', '--name-only', '-z', '-m', `${block.base_sha}..${newBase}`, '--'], tree);
|
|
595
|
+
const conflicts = [...new Set(upstream.stdout.split('\0').filter((file) => file.length > 0 && owns(file)))].sort();
|
|
596
|
+
if (conflicts.length > 0) throw new StateError('owned-conflict', `block ${id}: commits since its base changed owned files: ${conflicts.join(', ')}`);
|
|
597
|
+
}
|
|
598
|
+
const before = closed ? await committedBlobs(tree, reviewedHead, files) : null;
|
|
561
599
|
const moved = { old_base: block.base_sha, new_base: newBase };
|
|
562
600
|
try {
|
|
563
601
|
const rebased = await exec(['git', '-C', tree, 'rebase', newBase], { cwd: record.workspace, env: gitEnv(), timeoutMs: 30000 });
|
|
564
602
|
if (rebased.result !== 'ok') throw new StateError('rebase-conflict', 'rebase-conflict');
|
|
565
|
-
const after = await committedBlobs(tree, 'HEAD', files);
|
|
566
|
-
const changed = files.filter((file) => before.get(file) !== after.get(file));
|
|
567
|
-
|
|
603
|
+
const after = before === null ? null : await committedBlobs(tree, 'HEAD', files);
|
|
604
|
+
const changed = after === null ? [] : files.filter((file) => before.get(file) !== after.get(file));
|
|
605
|
+
const repoRoot = await repoRootOf(record.workspace);
|
|
606
|
+
if (rebaseCheck) {
|
|
607
|
+
// The worker reviews the authoritative worktree, so retain this candidate while leaving
|
|
608
|
+
// both bases untouched. Sign the original head so a later retry cannot forget changes.
|
|
609
|
+
block.rebase_check = signRow({ event: 'block.rebase_check', run: runId, block: id, ...moved, old_head: reviewedHead, head: await resolveCommit('HEAD', tree), started_at: pending?.started_at ?? new Date().toISOString() }, key);
|
|
610
|
+
const queued = changed.map((file) => reviewQueue.enqueue({ repoRoot, run: runId, block: id, file, tree, after: block.rebase_check.started_at }));
|
|
611
|
+
await saveRun(record);
|
|
612
|
+
return { ...moved, files: queued, pending: true };
|
|
613
|
+
}
|
|
614
|
+
const invalidAccepted = acceptChanged.filter((file) => !changed.includes(file));
|
|
615
|
+
if (invalidAccepted.length) throw new StateError('usage', `--accept-changed names files not changed by this rebase: ${invalidAccepted.join(', ')}`);
|
|
616
|
+
const rows = pending ? (await readAllRows(record.project)).filter((row) => row.run === runId && row.block === id && verifyRow(row, key).ok && Date.parse(row.ts) >= Date.parse(pending.started_at)) : [];
|
|
617
|
+
/** @type {Array<{file: string, via: string, ticket?: string, reason?: string}>} */
|
|
618
|
+
const cleared = [];
|
|
619
|
+
for (const file of changed) {
|
|
620
|
+
if (acceptChanged.includes(file)) {
|
|
621
|
+
cleared.push({ file, via: 'human', reason });
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
const hash = contentHash(tree, file);
|
|
625
|
+
const ticket = reviewQueue.resolveTicket({ repoRoot, run: runId, block: id, file, tree, after: pending?.started_at }).ticket;
|
|
626
|
+
const relevant = rows.filter((row) => row.file === file && row.content_hash === hash);
|
|
627
|
+
const approval = relevant.reduce((last, row, index) => row.event === 'review.approved' && (row.ticket === undefined || row.ticket === ticket) ? index : last, -1);
|
|
628
|
+
const denied = relevant.slice(approval + 1).some((row) => row.event === 'review.cap' || (row.event === 'review.result' && row.approved === false) || (row.event === 'review.round' && row.open_after > 0));
|
|
629
|
+
if (approval >= 0 && !denied) cleared.push({ file, via: 'review', ticket });
|
|
630
|
+
}
|
|
631
|
+
const uncleared = changed.filter((file) => !cleared.some((entry) => entry.file === file));
|
|
632
|
+
if (uncleared.length) throw new StateError('rebase-changed-reviewed', `rebase-changed-reviewed ${uncleared.join(', ')}\nRe-review: code-forge review-file --rebase-check --block ${id} --run ${runId}\nHuman accept (repeat --accept-changed for each file): code-forge block rebase ${id} --run ${runId} --accept-changed <file> --reason <text>`);
|
|
568
633
|
block.base_sha = newBase;
|
|
569
634
|
block.worktree.base_sha = newBase;
|
|
570
|
-
|
|
635
|
+
delete block.rebase_check;
|
|
636
|
+
await writeSigned(runId, writeRow, { event: 'block.rebase', block: id, ...moved, changed_reviewed: changed, cleared, ...(closed ? { closed: true } : { worktree: true }) });
|
|
571
637
|
await saveRun(record);
|
|
572
638
|
return moved;
|
|
573
639
|
} catch (err) {
|
|
@@ -679,9 +745,10 @@ export async function closeBlock({ runId, id, rows, writeRow, livePid, probe, ex
|
|
|
679
745
|
|
|
680
746
|
let pin = await checkWorkerPin(record, { livePid, probe });
|
|
681
747
|
const pins = record.workers ?? (record.worker ? [record.worker] : []);
|
|
748
|
+
const reviewPins = [...pins, ...(record.worker_history ?? [])];
|
|
682
749
|
const counted = rows.filter((row) => row.block === id && (row.run === undefined || row.run === runId) && row.event === 'review.result' && !['stale', 'unavailable', 'answers'].includes(row.status) && !(row.status === 'refused' && row.reason === 'other-run'));
|
|
683
750
|
// Ledgers written before B107 omit worker identity and use the run's pin check above.
|
|
684
|
-
if (counted.some((row) => row.worker !== undefined && !
|
|
751
|
+
if (counted.some((row) => row.worker !== undefined && !reviewPins.some((p) => p.pid === row.worker?.pid && p.started_at === row.worker?.started_at))) {
|
|
685
752
|
pin = { ok: false, event: 'worker.replaced', reason: 'a counted review result does not name a pinned worker' };
|
|
686
753
|
}
|
|
687
754
|
if (pin.ok) {
|
|
@@ -45,7 +45,7 @@ export const IMMUTABLE_KEYS = Object.freeze({
|
|
|
45
45
|
// B64b: the block-end hook runs a command on the owner's machine at every block end, so only
|
|
46
46
|
// the owner sets it, before the run starts
|
|
47
47
|
'hooks.on_block_end': 'it runs a command on the owner\'s machine at every block end; fixed by the owner before the run starts',
|
|
48
|
-
//
|
|
48
|
+
// B171: existing runner entries stay fixed; immutableChanges permits append-only additions.
|
|
49
49
|
'proof.runners': 'the commands that prove a block are fixed by the owner before the run starts',
|
|
50
50
|
// B93: the coder sandbox profile widens what the coder may write, so only the owner sets it,
|
|
51
51
|
// before the run starts (a coder can never own `.code-forge.yml`)
|
|
@@ -138,7 +138,17 @@ function valueAt(cfg, dotted) {
|
|
|
138
138
|
* @returns {string[]}
|
|
139
139
|
*/
|
|
140
140
|
export function immutableChanges(before, after) {
|
|
141
|
-
return Object.keys(IMMUTABLE_KEYS).filter((k) =>
|
|
141
|
+
return Object.keys(IMMUTABLE_KEYS).filter((k) => {
|
|
142
|
+
const oldValue = valueAt(before, k);
|
|
143
|
+
const newValue = valueAt(after, k);
|
|
144
|
+
// Additions go at the end: prepending could replace the first match of an existing runner.
|
|
145
|
+
if (k === 'proof.runners' && Array.isArray(newValue)) {
|
|
146
|
+
const oldRunners = oldValue ?? [];
|
|
147
|
+
if (Array.isArray(oldRunners) && newValue.length >= oldRunners.length
|
|
148
|
+
&& oldRunners.every((entry, i) => canonicalJSON(entry) === canonicalJSON(newValue[i]))) return false;
|
|
149
|
+
}
|
|
150
|
+
return canonicalJSON(oldValue ?? null) !== canonicalJSON(newValue ?? null);
|
|
151
|
+
});
|
|
142
152
|
}
|
|
143
153
|
|
|
144
154
|
/**
|
|
@@ -243,7 +253,7 @@ export function blockEndHook(record) {
|
|
|
243
253
|
}
|
|
244
254
|
|
|
245
255
|
/**
|
|
246
|
-
* B87: `proof.runners` of the
|
|
256
|
+
* B87: `proof.runners` of the snapshot in force (never the live config), as a deep copy
|
|
247
257
|
* (entries and argv arrays), or null when the key is absent or the record is from before
|
|
248
258
|
* snapshots. `proof/runners.mjs` checks each entry again before it runs one (fail closed).
|
|
249
259
|
* @param {Record<string, any>} record
|
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
|
}
|