@codedology/code-forge 0.15.3 → 0.16.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 +51 -0
- package/README.md +1 -1
- package/docs/reference/blocks.json +9 -1
- package/docs/reference/config.md +11 -1
- package/native/README.md +10 -0
- package/native/bin/darwin-arm64/code-forge-native +0 -0
- package/native/bin/darwin-x64/code-forge-native +0 -0
- package/native/bin/linux-arm64/code-forge-native +0 -0
- package/native/bin/linux-x64/code-forge-native +0 -0
- package/package.json +7 -2
- package/schema/code-forge.schema.json +26 -1
- package/schema/facts.schema.json +1 -1
- package/skill/SKILL.md +1 -1
- package/skill/references/adapters/solo.md +1 -1
- package/skill/references/autopilot.md +6 -1
- package/skill/references/code.md +37 -0
- package/skill/references/decisions.md +1 -1
- package/skill/references/plan.md +18 -5
- package/skill/references/review.md +47 -9
- package/skill/scripts/forge +1 -1
- package/skill/templates/facts-sheet.md +9 -3
- package/src/cli/block.mjs +284 -11
- package/src/cli/doctor.mjs +32 -1
- package/src/cli/help.mjs +1 -0
- package/src/cli/native-status.mjs +13 -0
- package/src/cli/review-file.mjs +126 -14
- package/src/cli/spawn.mjs +26 -22
- package/src/cli/status.mjs +80 -14
- package/src/decide/escalation.mjs +5 -5
- package/src/doctor/index.mjs +11 -0
- package/src/doctor/worker-probe.mjs +1 -1
- package/src/gates/dependents.mjs +200 -0
- package/src/gates/expand.mjs +83 -26
- package/src/gates/run.mjs +76 -14
- package/src/ledger/tail.mjs +89 -0
- package/src/review/agent-waiver.mjs +95 -8
- package/src/review/definitions.mjs +170 -0
- package/src/review/fixloop.mjs +4 -4
- package/src/review/gate-check.mjs +33 -13
- package/src/review/model-stats.mjs +125 -0
- package/src/review/packet.mjs +111 -22
- package/src/review/reopen.mjs +38 -13
- package/src/review/route.mjs +58 -4
- package/src/session/facts.mjs +252 -70
- package/src/session/plan-check.mjs +4 -1
- package/src/session/spawn.mjs +70 -19
- package/src/state/block-end.mjs +3 -1
- package/src/state/block.mjs +56 -21
- package/src/state/run.mjs +12 -5
- package/src/util/native.mjs +133 -0
- package/src/worker/engine.mjs +11 -26
- package/src/worker/loop.mjs +150 -20
- package/src/worker/queue.mjs +3 -2
- package/src/worker/review-state.mjs +15 -3
- package/src/worker/watch.mjs +97 -83
- package/src/worker/worker-warning.mjs +35 -0
package/src/session/spawn.mjs
CHANGED
|
@@ -45,7 +45,8 @@
|
|
|
45
45
|
|
|
46
46
|
import { spawn } from 'node:child_process';
|
|
47
47
|
import { createHash, randomBytes } from 'node:crypto';
|
|
48
|
-
import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
48
|
+
import { closeSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
49
|
+
import os from 'node:os';
|
|
49
50
|
import path from 'node:path';
|
|
50
51
|
import { Ajv2020 } from 'ajv/dist/2020.js';
|
|
51
52
|
import { CODEX_CLOSED_BOOK_REFUSAL, closedBookRefused, isNoToolRole, openBookCodexAllowed } from '../config/closed-book.mjs';
|
|
@@ -531,22 +532,17 @@ function assertFactsCwd(cwd, runRootDir) {
|
|
|
531
532
|
if (workTree !== null) throw refuse(`inside a git work tree (${workTree})`);
|
|
532
533
|
}
|
|
533
534
|
|
|
534
|
-
/**
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
* @returns {Promise<SessionResult>}
|
|
538
|
-
*/
|
|
539
|
-
export async function spawnSession(opts, deps = {}) {
|
|
540
|
-
const { cfg, level, role, promptPath } = opts;
|
|
535
|
+
/** @param {SessionOpts} opts */
|
|
536
|
+
function validateSessionOpts(opts) {
|
|
537
|
+
const { level, role, promptPath } = opts;
|
|
541
538
|
if (!LEVELS.includes(level)) throw new SessionError('usage', `level must be one of ${LEVELS.join(', ')}`);
|
|
542
539
|
if (!ROLES.includes(role)) throw new SessionError('usage', `role must be one of ${ROLES.join(', ')}`);
|
|
543
540
|
if (typeof promptPath !== 'string' || !path.isAbsolute(promptPath)) throw new SessionError('usage', 'promptPath must be an absolute path');
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
const writeRow = deps.writeRow ?? (opts.slug ? (/** @type {Record<string, any>} */ row) => appendRow(row, { slug: /** @type {string} */ (opts.slug) }) : null);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** @param {SessionOpts} opts @param {{write: (s: string) => unknown}} stderr */
|
|
544
|
+
function sessionSteps(opts, stderr) {
|
|
545
|
+
const { cfg, level, role } = opts;
|
|
550
546
|
const ladder = ladderFor(resolveLevel(cfg, level), role);
|
|
551
547
|
// B32: Codex has no no-tools mode, so it runs no reviewer/judge/S2/author session unless the
|
|
552
548
|
// config opts in (`review.allow_open_book_codex: true`). Without it, step 0 on openai refuses the
|
|
@@ -556,6 +552,53 @@ export async function spawnSession(opts, deps = {}) {
|
|
|
556
552
|
const steps = ladder.filter((step) => !closedBookRefused(step.provider, role, openBook));
|
|
557
553
|
if (steps.length < ladder.length) writeSafe(stderr, `fallback skipped: ${ladder.length - steps.length} openai step(s) — ${CODEX_CLOSED_BOOK_REFUSAL}\n`);
|
|
558
554
|
if (opts.background) steps.length = 1;
|
|
555
|
+
return { steps, openBook };
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Build the first runnable step without entering the budget gate or provider semaphore.
|
|
560
|
+
* All session artifacts are temporary; argv paths use the stable <session> placeholder.
|
|
561
|
+
* @param {SessionOpts} opts @param {SessionDeps} [deps]
|
|
562
|
+
* @returns {Promise<{argv: string[], cwd: string, provider: string, model: string, effort: string | null, fallback_step: number}>}
|
|
563
|
+
*/
|
|
564
|
+
export async function previewArgv(opts, deps = {}) {
|
|
565
|
+
validateSessionOpts(opts);
|
|
566
|
+
if (opts.role === 'facts' && opts.cwd !== undefined) assertFactsCwd(opts.cwd, opts.runRoot ?? currentRunRoot());
|
|
567
|
+
const { steps, openBook } = sessionSteps(opts, deps.stderr ?? process.stderr);
|
|
568
|
+
const step = steps[0];
|
|
569
|
+
const sessionDir = mkdtempSync(path.join(os.tmpdir(), 'code-forge-preview-'));
|
|
570
|
+
try {
|
|
571
|
+
const coderPrompt = opts.role === 'coder' && opts.block ? await coderPromptFor(opts) : null;
|
|
572
|
+
const built = buildStep({ ...opts, runRoot: sessionDir }, step, sessionDir, openBook, coderPrompt);
|
|
573
|
+
checkBuiltArgv(built, opts, deps);
|
|
574
|
+
return {
|
|
575
|
+
argv: built.argv.map((arg) => arg.replaceAll(sessionDir, '<session>')),
|
|
576
|
+
cwd: built.cwd.replaceAll(sessionDir, '<session>'),
|
|
577
|
+
provider: step.provider,
|
|
578
|
+
model: step.model,
|
|
579
|
+
effort: step.effort ?? null,
|
|
580
|
+
fallback_step: step.fallback_step,
|
|
581
|
+
};
|
|
582
|
+
} finally {
|
|
583
|
+
rmSync(sessionDir, { recursive: true, force: true });
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* @param {SessionOpts} opts
|
|
589
|
+
* @param {SessionDeps} [deps]
|
|
590
|
+
* @returns {Promise<SessionResult>}
|
|
591
|
+
*/
|
|
592
|
+
export async function spawnSession(opts, deps = {}) {
|
|
593
|
+
const { cfg, level, role } = opts;
|
|
594
|
+
validateSessionOpts(opts);
|
|
595
|
+
/** @type {string | null} */
|
|
596
|
+
let coderPrompt = null;
|
|
597
|
+
const stderr = deps.stderr ?? process.stderr;
|
|
598
|
+
const runRootDir = opts.runRoot ?? currentRunRoot();
|
|
599
|
+
if (role === 'facts' && opts.cwd !== undefined) assertFactsCwd(opts.cwd, runRootDir);
|
|
600
|
+
const writeRow = deps.writeRow ?? (opts.slug ? (/** @type {Record<string, any>} */ row) => appendRow(row, { slug: /** @type {string} */ (opts.slug) }) : null);
|
|
601
|
+
const { steps, openBook } = sessionSteps(opts, stderr);
|
|
559
602
|
|
|
560
603
|
const attempts = [];
|
|
561
604
|
const sleep = deps.sleep ?? realSleep;
|
|
@@ -612,11 +655,7 @@ export async function spawnSession(opts, deps = {}) {
|
|
|
612
655
|
try {
|
|
613
656
|
const built = buildStep(opts, step, sessionDir, openBook, coderPrompt);
|
|
614
657
|
const cli = built.cli;
|
|
615
|
-
built
|
|
616
|
-
if (role === 'coder') {
|
|
617
|
-
const hit = isForbidden(built.argv, opts.forbidden ?? mergeForbidden());
|
|
618
|
-
if (hit) throw new SessionError('forbidden', `coder argv refused before spawn: forbidden entry ${hit.id}`);
|
|
619
|
-
}
|
|
658
|
+
checkBuiltArgv(built, opts, deps);
|
|
620
659
|
writeSafe(stderr, `level=${level} provider=${step.provider} model=${step.model} effort=${step.effort ?? 'none'} fallback_step=${step.fallback_step}\n`);
|
|
621
660
|
const base = { role, level, provider: step.provider, model: step.model, effort: step.effort ?? null, fallback_step: step.fallback_step, run: opts.run ?? null, block: opts.block ?? null, ...(role === 's2' ? { source: step.fallback_step > 0 ? 's2-fallback' : 's2' } : {}), ...sandboxRowField(opts, cli), ...(opts.rowExtra ?? {}) };
|
|
622
661
|
|
|
@@ -910,6 +949,18 @@ function buildStep(opts, step, sessionDir, openBook, coderPrompt = null) {
|
|
|
910
949
|
return { ...built, argv: [...built.argv], compiled, strict };
|
|
911
950
|
}
|
|
912
951
|
|
|
952
|
+
/**
|
|
953
|
+
* Apply the binary override before the coder's forbidden-argv check, in both launch modes.
|
|
954
|
+
* @param {{cli: string, argv: string[]}} built @param {SessionOpts} opts @param {SessionDeps} deps
|
|
955
|
+
*/
|
|
956
|
+
function checkBuiltArgv(built, opts, deps) {
|
|
957
|
+
built.argv[0] = deps.bins?.[built.cli] ?? built.argv[0];
|
|
958
|
+
if (opts.role === 'coder') {
|
|
959
|
+
const hit = isForbidden(built.argv, opts.forbidden ?? mergeForbidden());
|
|
960
|
+
if (hit) throw new SessionError('forbidden', `coder argv refused before spawn: forbidden entry ${hit.id}`);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
913
964
|
/**
|
|
914
965
|
* B93: `{sandbox: <profile>}` for a Codex coder session row when `coder.sandbox` is set; `{}`
|
|
915
966
|
* otherwise, so a row without the key is byte-equal to the row before B93.
|
package/src/state/block-end.mjs
CHANGED
|
@@ -25,6 +25,7 @@ import path from 'node:path';
|
|
|
25
25
|
import { runSpend } from '../ledger/spend.mjs';
|
|
26
26
|
import { readAllRows } from '../ledger/write.mjs';
|
|
27
27
|
import { exec } from '../util/exec.mjs';
|
|
28
|
+
import { daemonRequest } from '../util/native.mjs';
|
|
28
29
|
import { assertRunId, runsDir } from './paths.mjs';
|
|
29
30
|
import { withRunLock, writeSigned } from './run.mjs';
|
|
30
31
|
|
|
@@ -113,7 +114,8 @@ export async function writeDoneFile({ record, id, status, event, reason = null,
|
|
|
113
114
|
/** @type {Record<string, any>[] | null} */
|
|
114
115
|
let rows = null;
|
|
115
116
|
try {
|
|
116
|
-
|
|
117
|
+
const reply = await daemonRequest(runId, { method: 'rows', block: id }, { config: record.config?.snapshots?.[record.config?.hash] });
|
|
118
|
+
rows = reply?.rows ?? await readAllRows(record.project);
|
|
117
119
|
} catch {
|
|
118
120
|
rows = null;
|
|
119
121
|
}
|
package/src/state/block.mjs
CHANGED
|
@@ -27,7 +27,7 @@ import { checkWorkerPin, readRun, saveRun, withRunLock, writeSigned } from './ru
|
|
|
27
27
|
import { canonicalJSON, verifyRow, loadKey, signRow } from './signer.mjs';
|
|
28
28
|
import { readAllRows } from '../ledger/write.mjs';
|
|
29
29
|
import * as reviewQueue from '../worker/queue.mjs';
|
|
30
|
-
import { contentHash, repoRootOf } from '../worker/ticket.mjs';
|
|
30
|
+
import { assertInsideRoot, contentHash, repoRootOf } from '../worker/ticket.mjs';
|
|
31
31
|
|
|
32
32
|
/** @typedef {import('./run.mjs').WriteRow} WriteRow */
|
|
33
33
|
|
|
@@ -293,6 +293,16 @@ function activeBlocks(record) {
|
|
|
293
293
|
return Object.fromEntries(Object.entries(record.blocks).filter(([, b]) => b.status === 'open'));
|
|
294
294
|
}
|
|
295
295
|
|
|
296
|
+
/** Earlier claims are authoritative only when their run, block and MAC match.
|
|
297
|
+
* @param {Record<string, any>} record @param {string} id @returns {Promise<string[]>}
|
|
298
|
+
*/
|
|
299
|
+
async function signedClaims(record, id) {
|
|
300
|
+
const key = await loadKey(record.run_id);
|
|
301
|
+
const rows = await readAllRows(record.project);
|
|
302
|
+
return [...new Set(rows.filter((row) => row.event === 'block.claim' && row.run === record.run_id && row.block === id && verifyRow(row, key).ok)
|
|
303
|
+
.map((row) => assertOwned([row.path])[0]))];
|
|
304
|
+
}
|
|
305
|
+
|
|
296
306
|
/**
|
|
297
307
|
* B58: with `review.external.required` set in the run's snapshot, the project's `.code-forge.yml`
|
|
298
308
|
* is the owner's switch past the local per-file review, so no block may own it. These are the
|
|
@@ -387,7 +397,7 @@ function refuseConfigOwner(owned, kinds, guarded) {
|
|
|
387
397
|
* decision?: {question: 'lane', answer: string, source: 'rules', facts: Record<string, unknown>}, decisionId?: string,
|
|
388
398
|
* worktree?: (ctx: {baseSha: string, baseExplicit: boolean, previous: TreeRecord | null}) => Promise<{path: string, make: () => Promise<OpenTree>}>,
|
|
389
399
|
* }} opts
|
|
390
|
-
* @returns {Promise<{block: Record<string, any>, pointer: string | null, worktreeReused: boolean}>} `worktreeReused`:
|
|
400
|
+
* @returns {Promise<{block: Record<string, any>, pointer: string | null, worktreeReused: boolean, keptClaims: number}>} `worktreeReused`:
|
|
391
401
|
* the block took an earlier attempt's worktree as it was.
|
|
392
402
|
*/
|
|
393
403
|
export async function openBlock(opts) {
|
|
@@ -398,12 +408,12 @@ export async function openBlock(opts) {
|
|
|
398
408
|
if (lines !== null && !(Number.isInteger(lines) && lines > 0)) throw new StateError('bad-lines', 'lines forecast must be a positive integer');
|
|
399
409
|
if (opts.kind !== undefined && opts.kind !== 'light' && !BLOCK_KINDS.includes(opts.kind)) throw new StateError('bad-kind', `kind must be one of ${[...BLOCK_KINDS, 'light'].join(', ')}`);
|
|
400
410
|
if (opts.decision && opts.decision.answer !== level) throw new StateError('bad-decision', `the decision answer ${opts.decision.answer} is not the requested level ${level}`);
|
|
401
|
-
const
|
|
411
|
+
const declaredOwned = assertOwned(opts.owned);
|
|
402
412
|
const acceptance = assertAcceptance(opts.acceptance);
|
|
403
413
|
const humanChecks = acceptance.filter((c) => c.kind === 'human_check').map((c) => c.clause);
|
|
404
414
|
const tests = opts.tests === undefined ? null : [...assertOwned(opts.tests, { label: 'test files', code: 'bad-tests' })];
|
|
405
415
|
const after = opts.after === undefined ? null : [...new Set(opts.after)];
|
|
406
|
-
const kind = opts.kind === 'light' ? 'light' : blockKind({ owned, declared: opts.kind });
|
|
416
|
+
const kind = opts.kind === 'light' ? 'light' : blockKind({ owned: declaredOwned, declared: opts.kind });
|
|
407
417
|
const dispatched = dispatchLevel({ lane: level, kind, cfg: opts.cfg });
|
|
408
418
|
const pointer = opts.brief ? briefPointer(opts.brief.path, opts.brief.content) : null;
|
|
409
419
|
|
|
@@ -428,7 +438,7 @@ export async function openBlock(opts) {
|
|
|
428
438
|
/** @type {OpenTree | null} */
|
|
429
439
|
let made = null;
|
|
430
440
|
try {
|
|
431
|
-
const { block, reused } = await mutateRun(runId, null, writeRow, async (record) => {
|
|
441
|
+
const { block, reused, keptClaims } = await mutateRun(runId, null, writeRow, async (record) => {
|
|
432
442
|
if (record.blocks[id]?.status === 'open') throw new StateError('already-open', `block ${id} is already open`);
|
|
433
443
|
if (record.blocks[id]?.status === 'awaiting_human_check') throw new StateError('already-open', `block ${id} is awaiting a human check`);
|
|
434
444
|
if (after !== null) {
|
|
@@ -441,6 +451,8 @@ export async function openBlock(opts) {
|
|
|
441
451
|
}
|
|
442
452
|
// B57: what each exact owned entry is on disk, under the lock (as `block claim`) — a regular
|
|
443
453
|
// file owns only itself, here and in every later check
|
|
454
|
+
const claims = attempt > 1 ? await signedClaims(record, id) : [];
|
|
455
|
+
const owned = [...new Set([...declaredOwned, ...claims])];
|
|
444
456
|
const kinds = ownedKinds(record.workspace, owned);
|
|
445
457
|
refuseConfigOwner(owned, kinds, guarded);
|
|
446
458
|
const prior = previousTree(record);
|
|
@@ -467,7 +479,7 @@ export async function openBlock(opts) {
|
|
|
467
479
|
const floor = dispatched.trigger === 'docs_floor' ? { requested_level: level, trigger: 'docs_floor' } : {};
|
|
468
480
|
const created = tree?.created ? [{ event: 'worktree.created', block: id, path: tree.worktree.path, branch: tree.worktree.branch, base: tree.worktree.base_sha }] : [];
|
|
469
481
|
return {
|
|
470
|
-
result: { block: entry, reused: tree !== null && !tree.created },
|
|
482
|
+
result: { block: entry, reused: tree !== null && !tree.created, keptClaims: claims.length },
|
|
471
483
|
rows: [
|
|
472
484
|
...(opts.decision ? [{ ...opts.decision, event: 'decision', decision_id: opts.decisionId ?? randomUUID(), block: id }] : []),
|
|
473
485
|
...created,
|
|
@@ -475,7 +487,7 @@ export async function openBlock(opts) {
|
|
|
475
487
|
],
|
|
476
488
|
};
|
|
477
489
|
});
|
|
478
|
-
return { block, pointer, worktreeReused: reused };
|
|
490
|
+
return { block, pointer, worktreeReused: reused, keptClaims };
|
|
479
491
|
} catch (err) {
|
|
480
492
|
// B62a: nothing was recorded, so a tree this open made must not outlive it
|
|
481
493
|
if (made?.created && made.rollback) {
|
|
@@ -493,9 +505,12 @@ export async function openBlock(opts) {
|
|
|
493
505
|
|
|
494
506
|
/** @param {{runId: string, id: string, writeRow: WriteRow}} opts */
|
|
495
507
|
export async function attemptBlock({ runId, id, writeRow }) {
|
|
496
|
-
return mutateRun(runId, id, writeRow, (
|
|
508
|
+
return mutateRun(runId, id, writeRow, async (record, block) => {
|
|
509
|
+
const claims = await signedClaims(record, id);
|
|
510
|
+
block.owned_files = [...new Set([...block.owned_files, ...claims])];
|
|
511
|
+
block.owned_kinds = { ...ownedKinds(record.workspace, claims), ...block.owned_kinds };
|
|
497
512
|
block.attempt += 1;
|
|
498
|
-
return { result: block.attempt, rows: [{ event: 'block.attempt', block: id, attempt: block.attempt }] };
|
|
513
|
+
return { result: { attempt: block.attempt, keptClaims: claims.length }, rows: [{ event: 'block.attempt', block: id, attempt: block.attempt }] };
|
|
499
514
|
});
|
|
500
515
|
}
|
|
501
516
|
|
|
@@ -598,8 +613,20 @@ async function rebaseWorktreeBlock({ runId, id, writeRow, acceptChanged, reason,
|
|
|
598
613
|
const before = closed ? await committedBlobs(tree, reviewedHead, files) : null;
|
|
599
614
|
const moved = { old_base: block.base_sha, new_base: newBase };
|
|
600
615
|
try {
|
|
601
|
-
|
|
602
|
-
|
|
616
|
+
try {
|
|
617
|
+
// Read every original identity once. Exclude commits already in the new base, including
|
|
618
|
+
// upstream commits retained by a pending rebase check. Global config and GIT_* stay scrubbed.
|
|
619
|
+
const commits = await git(['git', 'log', '--format=%H%x00%cn%x00%ce', '--reverse', `${block.base_sha}..${oldHead}`, '--not', newBase], tree);
|
|
620
|
+
await git(['git', 'reset', '--hard', newBase], tree);
|
|
621
|
+
for (const commit of commits.stdout.trimEnd().split('\n').filter(Boolean)) {
|
|
622
|
+
const [sha, committerName, committerEmail] = commit.split('\0');
|
|
623
|
+
// No -m: cherry-pick refuses merge commits. An original empty commit is still a commit.
|
|
624
|
+
const replayed = await exec(['git', '-C', tree, 'cherry-pick', '--allow-empty', sha], { cwd: record.workspace, env: { ...gitEnv(), GIT_COMMITTER_NAME: committerName, GIT_COMMITTER_EMAIL: committerEmail }, timeoutMs: 30000 });
|
|
625
|
+
if (replayed.result !== 'ok') throw new StateError('rebase-conflict', 'rebase-conflict');
|
|
626
|
+
}
|
|
627
|
+
} catch {
|
|
628
|
+
throw new StateError('rebase-conflict', 'rebase-conflict');
|
|
629
|
+
}
|
|
603
630
|
const after = before === null ? null : await committedBlobs(tree, 'HEAD', files);
|
|
604
631
|
const changed = after === null ? [] : files.filter((file) => before.get(file) !== after.get(file));
|
|
605
632
|
const repoRoot = await repoRootOf(record.workspace);
|
|
@@ -637,9 +664,9 @@ async function rebaseWorktreeBlock({ runId, id, writeRow, acceptChanged, reason,
|
|
|
637
664
|
await saveRun(record);
|
|
638
665
|
return moved;
|
|
639
666
|
} catch (err) {
|
|
640
|
-
//
|
|
641
|
-
//
|
|
642
|
-
await exec(['git', '-C', tree, '
|
|
667
|
+
// Abort a conflicted replay; after a completed replay there is nothing to abort.
|
|
668
|
+
// Reset restores the exact old branch head. The tree was clean before either.
|
|
669
|
+
await exec(['git', '-C', tree, 'cherry-pick', '--abort'], { cwd: record.workspace, env: gitEnv(), timeoutMs: 30000 });
|
|
643
670
|
await git(['git', 'reset', '--hard', oldHead], tree);
|
|
644
671
|
throw err;
|
|
645
672
|
}
|
|
@@ -654,25 +681,33 @@ async function rebaseWorktreeBlock({ runId, id, writeRow, acceptChanged, reason,
|
|
|
654
681
|
* B58: with `review.external.required` set, a path that owns the project's config is refused
|
|
655
682
|
* (`config-owned`), as at `block open`. B62a: two blocks that each have their own worktree may
|
|
656
683
|
* own the same path (`blocksOverlap`).
|
|
657
|
-
* @param {{runId: string, id: string, file: string, writeRow: WriteRow}} opts
|
|
684
|
+
* @param {{runId: string, id: string, file: string, writeRow: WriteRow, auto?: boolean}} opts
|
|
658
685
|
*/
|
|
659
|
-
export async function claimPath({ runId, id, file, writeRow }) {
|
|
686
|
+
export async function claimPath({ runId, id, file, writeRow, auto = false }) {
|
|
660
687
|
assertOwned([file]);
|
|
661
688
|
if (!isExactEntry(file)) throw new StateError('bad-owned', `block claim takes one exact path, not a glob (${JSON.stringify(file)})`);
|
|
662
689
|
const guarded = await guardedConfigPaths(await readRun(runId));
|
|
663
|
-
await mutateRun(runId, id, writeRow, (record, block) => {
|
|
664
|
-
const
|
|
690
|
+
await mutateRun(runId, id, writeRow, async (record, block) => {
|
|
691
|
+
const root = auto ? await repoTop(block.worktree?.path ?? record.workspace) : record.workspace;
|
|
692
|
+
if (auto) {
|
|
693
|
+
if (file.split('/').some((part) => ['.git', '.code-forge'].includes(part.toLowerCase()))) throw new StateError('unowned_change', `unowned_change ${file}: cannot auto-claim repository state`);
|
|
694
|
+
try { assertInsideRoot(root, file); }
|
|
695
|
+
catch { throw new StateError('unowned_change', `unowned_change ${file}: cannot auto-claim a path outside the repository or a symlink`); }
|
|
696
|
+
}
|
|
697
|
+
const kinds = { [file]: pathKind(root, file) };
|
|
665
698
|
refuseConfigOwner([file], kinds, guarded);
|
|
699
|
+
const prefix = auto ? path.relative(await repoTop(record.workspace), realpathSync(record.workspace)).split(path.sep).join('/') : '';
|
|
666
700
|
for (const [otherId, other] of Object.entries(activeBlocks(record))) {
|
|
667
|
-
const pair = otherId === id ? null : blocksOverlap({ owned_files: [file], owned_kinds: kinds, worktree: block.worktree ?? null }, other);
|
|
668
|
-
|
|
701
|
+
const pair = otherId === id ? null : blocksOverlap({ owned_files: [file], owned_kinds: kinds, worktree: auto ? null : block.worktree ?? null }, other);
|
|
702
|
+
const relativeOwner = auto && otherId !== id && prefix !== '' && file.startsWith(`${prefix}/`) && ownsFile(other.owned_files, file.slice(prefix.length + 1), other.owned_kinds);
|
|
703
|
+
if (pair || relativeOwner) throw new StateError(auto ? 'unowned_change' : 'overlap', `${auto ? 'unowned_change ' : ''}${file} overlaps ${pair?.[1] ?? file} owned by open block ${otherId}`);
|
|
669
704
|
}
|
|
670
705
|
if (!block.owned_files.includes(file)) {
|
|
671
706
|
block.owned_files.push(file);
|
|
672
707
|
block.owned_kinds = { ...block.owned_kinds, ...kinds };
|
|
673
708
|
}
|
|
674
709
|
record.orphans = record.orphans.filter((/** @type {string} */ o) => !ownsFile([file], o, kinds));
|
|
675
|
-
return { result: undefined, rows: [{ event: 'block.claim', block: id, path: file }] };
|
|
710
|
+
return { result: undefined, rows: [{ event: 'block.claim', block: id, path: file, ...(auto ? { auto: true, reason: 'auto-claim: changed outside owned files' } : {}) }] };
|
|
676
711
|
});
|
|
677
712
|
}
|
|
678
713
|
|
package/src/state/run.mjs
CHANGED
|
@@ -26,6 +26,11 @@ import { generateKey, loadKey, signRow } from './signer.mjs';
|
|
|
26
26
|
const LOCK_TIMEOUT_MS = 5000;
|
|
27
27
|
const LOCK_POLL_MS = 25;
|
|
28
28
|
|
|
29
|
+
/** @param {string} text @returns {string | null} */
|
|
30
|
+
export function normalizeStartTime(text) {
|
|
31
|
+
return text.trim().replace(/\s+/g, ' ') || null;
|
|
32
|
+
}
|
|
33
|
+
|
|
29
34
|
/**
|
|
30
35
|
* The process start time of `pid` (`ps -o lstart=`), or null when no such process exists.
|
|
31
36
|
* @type {StartTimeProbe}
|
|
@@ -37,8 +42,7 @@ export async function processStartTime(pid) {
|
|
|
37
42
|
okExitCodes: [0, 1],
|
|
38
43
|
timeoutMs: 10000,
|
|
39
44
|
});
|
|
40
|
-
|
|
41
|
-
return res.result === 'ok' && res.code === 0 && text.length > 0 ? text : null;
|
|
45
|
+
return res.result === 'ok' && res.code === 0 ? normalizeStartTime(res.stdout) : null;
|
|
42
46
|
}
|
|
43
47
|
|
|
44
48
|
/** @param {unknown} value @returns {string} */
|
|
@@ -348,7 +352,10 @@ export async function checkWorkerPin(record, { livePid, probe = processStartTime
|
|
|
348
352
|
return { ok: false, event: 'worker.replaced', reason: 'the live worker does not match a pinned worker' };
|
|
349
353
|
}
|
|
350
354
|
if (livePid === undefined) {
|
|
351
|
-
const live = await Promise.all(pins.map(async (p) =>
|
|
355
|
+
const live = await Promise.all(pins.map(async (p) => {
|
|
356
|
+
const startedAt = await probe(p.pid);
|
|
357
|
+
return startedAt !== null && startedAt === normalizeStartTime(p.started_at);
|
|
358
|
+
}));
|
|
352
359
|
return live.some(Boolean) ? { ok: true } : { ok: false, event: 'worker.down', reason: 'no pinned worker is running' };
|
|
353
360
|
}
|
|
354
361
|
pin = pins.find((p) => p.pid === livePid);
|
|
@@ -360,7 +367,7 @@ export async function checkWorkerPin(record, { livePid, probe = processStartTime
|
|
|
360
367
|
}
|
|
361
368
|
const startedAt = await probe(pin.pid);
|
|
362
369
|
if (startedAt === null) return { ok: false, event: 'worker.down', reason: `pinned worker pid ${pin.pid} is not running` };
|
|
363
|
-
if (startedAt !== pin.started_at) {
|
|
370
|
+
if (startedAt !== normalizeStartTime(pin.started_at)) {
|
|
364
371
|
return { ok: false, event: 'worker.replaced', reason: `pid ${pin.pid} was restarted (start time differs from the pin)` };
|
|
365
372
|
}
|
|
366
373
|
return { ok: true };
|
|
@@ -378,7 +385,7 @@ export async function stopPinnedWorker(pin, { probe = processStartTime, kill = (
|
|
|
378
385
|
if (!pin || !isSignalablePid(pin.pid)) return { stopped: false, reason: 'invalid pid' };
|
|
379
386
|
const startedAt = await probe(pin.pid);
|
|
380
387
|
if (startedAt === null) return { stopped: false, reason: 'not running' };
|
|
381
|
-
if (startedAt !== pin.started_at) return { stopped: false, reason: 'pid reused' };
|
|
388
|
+
if (startedAt !== normalizeStartTime(pin.started_at)) return { stopped: false, reason: 'pid reused' };
|
|
382
389
|
try {
|
|
383
390
|
kill(pin.pid, 'SIGTERM');
|
|
384
391
|
} catch (err) {
|
|
@@ -0,0 +1,133 @@
|
|
|
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 === 'rows' && (!Array.isArray(result.rows)
|
|
87
|
+
|| !result.rows.every((row) => row !== null && typeof row === 'object' && !Array.isArray(row)))) return finish(null);
|
|
88
|
+
if (request.method === 'wait-block' && (result.block !== request.block || !Array.isArray(result.files) || typeof result.done !== 'boolean'
|
|
89
|
+
|| !result.files.every((/** @type {any} */ file) => file && typeof file.file === 'string' && typeof file.status === 'string'))) return finish(null);
|
|
90
|
+
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);
|
|
91
|
+
finish(result);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}).catch(() => null);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The daemon serializes startup with its own OS lock. A loser exits harmlessly;
|
|
99
|
+
* only this child is stopped, never a daemon another worker already owns.
|
|
100
|
+
* @param {string} run @param {Record<string, any> | null} config
|
|
101
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
102
|
+
* @returns {Promise<() => Promise<void>>}
|
|
103
|
+
*/
|
|
104
|
+
export async function startNativeDaemon(run, config, env = process.env) {
|
|
105
|
+
const noop = async () => {};
|
|
106
|
+
if (!daemonEnabled(config, env)) return noop;
|
|
107
|
+
const binary = nativePath();
|
|
108
|
+
if (!binary || await daemonRequest(run, { method: 'ping' }, { config })) return noop;
|
|
109
|
+
let child;
|
|
110
|
+
try { child = spawn(binary, ['serve', '--home', os.homedir(), '--run', run], { stdio: 'ignore', env }); }
|
|
111
|
+
catch { return noop; }
|
|
112
|
+
let ended = false;
|
|
113
|
+
const exited = new Promise((resolve) => {
|
|
114
|
+
child.once('error', () => { ended = true; resolve(undefined); });
|
|
115
|
+
child.once('exit', () => { ended = true; resolve(undefined); });
|
|
116
|
+
});
|
|
117
|
+
for (let i = 0; i < 100 && !ended; i++) {
|
|
118
|
+
if (await daemonRequest(run, { method: 'ping' }, { config, timeoutMs: 100 })) break;
|
|
119
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
120
|
+
}
|
|
121
|
+
/** @type {Promise<void> | undefined} */
|
|
122
|
+
let stopping;
|
|
123
|
+
return () => {
|
|
124
|
+
stopping ??= (async () => {
|
|
125
|
+
if (ended) return;
|
|
126
|
+
child.kill('SIGTERM');
|
|
127
|
+
const timer = setTimeout(() => child.kill('SIGKILL'), 2000);
|
|
128
|
+
timer.unref();
|
|
129
|
+
try { await exited; } finally { clearTimeout(timer); }
|
|
130
|
+
})();
|
|
131
|
+
return stopping;
|
|
132
|
+
};
|
|
133
|
+
}
|
package/src/worker/engine.mjs
CHANGED
|
@@ -108,7 +108,7 @@ import { ledgerSchemaHistory } from '../review/schema-fallback.mjs';
|
|
|
108
108
|
import { verifyRow } from '../state/signer.mjs';
|
|
109
109
|
import { redact } from '../util/redact.mjs';
|
|
110
110
|
import { withLock } from '../util/file-locks.mjs';
|
|
111
|
-
import {
|
|
111
|
+
import { fileRungUsed, loadState, RESTORED_EVENT, saveState } from './review-state.mjs';
|
|
112
112
|
import { assertAnswers, enqueueHash, isDone, reviewExclusionFor, writeResult } from './queue.mjs';
|
|
113
113
|
import { assertTicketId, contentHash, readRegularFileNoFollow, WorkerError } from './ticket.mjs';
|
|
114
114
|
|
|
@@ -527,7 +527,7 @@ export function blockPeerDiffs(repoRoot, { base, owned, ownedKinds, file }) {
|
|
|
527
527
|
}
|
|
528
528
|
|
|
529
529
|
/**
|
|
530
|
-
* The key of a block's lock (B42): the
|
|
530
|
+
* The key of a block's lock (B42): the budget row and each file's L3 rung are taken under it.
|
|
531
531
|
* It carries the run, so two runs in one process never share a lock.
|
|
532
532
|
* @param {string} runId @param {string} block @returns {string}
|
|
533
533
|
*/
|
|
@@ -537,33 +537,18 @@ export const blockLockKey = (runId, block) => `block:${runId}\0${block}`;
|
|
|
537
537
|
export const LOST_MESSAGE = 'the review state file is gone and the ledger cannot rebuild it (its anchor carries no state_body); the file is not reviewed again from round 1 — the owner can run `code-forge block reopen`';
|
|
538
538
|
|
|
539
539
|
/**
|
|
540
|
-
* Whether
|
|
541
|
-
*
|
|
542
|
-
*
|
|
543
|
-
* does not load is counted as taken (fail closed; its own ticket refuses it).
|
|
540
|
+
* Whether this file's L3 rung is taken, derived from its verified anchors (`fileRungUsed`).
|
|
541
|
+
* A pending `next: patch` on this file is handled by its patch_check ticket; sibling files
|
|
542
|
+
* have their own rungs.
|
|
544
543
|
* @param {import('./review-state.mjs').Where} where
|
|
545
544
|
* @returns {{status: 'ok' | 'tampered', used: boolean}}
|
|
546
545
|
*/
|
|
547
546
|
export function rungTaken(where) {
|
|
548
|
-
|
|
549
|
-
if (rung.status === 'tampered' || rung.used) return rung;
|
|
550
|
-
const anchors = blockAnchors(where) ?? [];
|
|
551
|
-
const siblings = new Set(anchors.map((a) => a.file).filter((f) => typeof f === 'string' && f !== where.file));
|
|
552
|
-
for (const file of siblings) {
|
|
553
|
-
let other;
|
|
554
|
-
try {
|
|
555
|
-
other = loadState({ ...where, file, migrate: false }); // B69: a sibling's old file is read, never moved here
|
|
556
|
-
} catch {
|
|
557
|
-
return { status: 'ok', used: true };
|
|
558
|
-
}
|
|
559
|
-
// a sibling that would be restored (B68) counts by its rebuilt state; lost or tampered fails closed
|
|
560
|
-
if ((other.status !== 'ok' && other.status !== 'restored') || other.state?.next?.action === 'patch') return { status: 'ok', used: true };
|
|
561
|
-
}
|
|
562
|
-
return rung;
|
|
547
|
+
return fileRungUsed(where);
|
|
563
548
|
}
|
|
564
549
|
|
|
565
550
|
/**
|
|
566
|
-
* Whether this round could take the
|
|
551
|
+
* Whether this round could take the file's L3 rung (so it must run under the block lock): never
|
|
567
552
|
* when the rung is taken or the round is the patch_check (the rung is already this file's); never
|
|
568
553
|
* for a file's first full round while the cap and the per-level ladder are both above 1 (round 1
|
|
569
554
|
* cannot stall, reach the cap or exhaust the ladder); else conservatively yes.
|
|
@@ -586,9 +571,9 @@ export function mayTakeRung(state, kind, cfg, rungUsed) {
|
|
|
586
571
|
* Concurrency (B42): the worker runs several tickets at once but never two for the same
|
|
587
572
|
* (block, file) (its file lock, `loop.mjs`), so this file's state `seq` is read and saved by one
|
|
588
573
|
* ticket at a time. What the files of a BLOCK share is taken under the block lock
|
|
589
|
-
* (`blockLockKey`): the budget row's check-then-write (written once per block) and
|
|
574
|
+
* (`blockLockKey`): the budget row's check-then-write (written once per block) and each file's L3 rung —
|
|
590
575
|
* the rung read and, for a round that could take the rung (`mayTakeRung`), the whole round and its
|
|
591
|
-
* state save, so
|
|
576
|
+
* state save, so workers cannot take the same file's rung twice. A round that cannot take the rung (a
|
|
592
577
|
* first full round, or any round once the rung is gone) runs outside the block lock, in parallel
|
|
593
578
|
* with the block's other files.
|
|
594
579
|
* @param {import('./queue.mjs').Ticket} ticket @param {ReviewContext} ctx
|
|
@@ -636,7 +621,7 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, block
|
|
|
636
621
|
if (row.event === 'review.state' && row.file === ticket.file) anchoredAt = i;
|
|
637
622
|
});
|
|
638
623
|
// A changed review input starts a full round, including previously stopped files. Keep
|
|
639
|
-
// the anchor sequence and the
|
|
624
|
+
// the anchor sequence and the file's spent L3 rung; tampered state still refuses above.
|
|
640
625
|
const reopened = amendedAt > anchoredAt || reopenRowFor(rows, reopenAt) !== null;
|
|
641
626
|
if (loaded.status === 'lost' && !reopened) return { outcome: /** @type {ReviewOutcome} */ ({ status: 'refused', reason: 'review-state-lost', message: LOST_MESSAGE, approved: false, engine: 'adaptive', sessions: [] }) };
|
|
642
627
|
let seq = loaded.seq;
|
|
@@ -664,7 +649,7 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, block
|
|
|
664
649
|
if (state.next?.action === 'retry') kind = state.pending_kind ?? kind;
|
|
665
650
|
else if (state.next?.action === 'patch') {
|
|
666
651
|
kind = 'patch_check';
|
|
667
|
-
state.l3_rung_used = true; // the orchestrator ran
|
|
652
|
+
state.l3_rung_used = true; // the orchestrator ran this file's one L3 patch before this ticket
|
|
668
653
|
}
|
|
669
654
|
const fixed = state;
|
|
670
655
|
const round = () => playRound(ticket, ctx, { state: fixed, kind, where, seq, base, blockKindOf, workDir, factsExcerpt, rulesDigest, extraRounds, peerDiffs, fileContext });
|