@codedology/code-forge 0.5.1 → 0.6.1
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 +59 -0
- package/README.md +39 -2
- package/docs/README.md +5 -4
- package/docs/reference/blocks.json +127 -1
- package/docs/reference/config.md +2 -0
- package/package.json +1 -1
- package/schema/code-forge.schema.json +7 -0
- package/skill/SKILL.md +2 -0
- package/skill/references/adapters/claude-code.md +14 -0
- package/skill/references/autopilot.md +48 -0
- package/skill/references/code.md +1 -1
- package/skill/references/decisions.md +3 -1
- package/skill/references/harden.md +1 -1
- package/skill/references/ledger.md +2 -0
- package/skill/references/plan.md +6 -4
- package/skill/references/review.md +2 -2
- package/skill/references/rules-core.md +4 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/plan.md +4 -3
- package/src/autopilot/actions.mjs +491 -0
- package/src/autopilot/binnacle.mjs +494 -0
- package/src/autopilot/delegate.mjs +422 -0
- package/src/autopilot/grant.mjs +354 -0
- package/src/autopilot/limits.mjs +848 -0
- package/src/autopilot/scopes.mjs +95 -0
- package/src/cli/author.mjs +7 -5
- package/src/cli/autopilot.mjs +500 -0
- package/src/cli/block.mjs +40 -8
- package/src/cli/facts.mjs +7 -6
- package/src/cli/jev.mjs +10 -6
- package/src/cli/logs.mjs +4 -142
- package/src/cli/plan.mjs +18 -7
- package/src/cli/run.mjs +20 -61
- package/src/cli/s2.mjs +5 -4
- package/src/cli/spawn.mjs +5 -4
- package/src/config/closed-book.mjs +20 -3
- package/src/config/load.mjs +113 -1
- package/src/doctor/probes.mjs +34 -11
- package/src/engines/builders/claude.mjs +3 -3
- package/src/engines/builders/codex.mjs +6 -5
- package/src/engines/builders/grok.mjs +3 -3
- package/src/engines/builders/validate-params.mjs +3 -3
- package/src/ledger/report.mjs +2 -0
- package/src/review/fixloop.mjs +69 -5
- package/src/review/gate-check.mjs +156 -6
- package/src/session/facts.mjs +291 -73
- package/src/session/plan-check.mjs +75 -6
- package/src/session/spawn.mjs +68 -8
- package/src/state/run.mjs +8 -1
- package/src/util/breadcrumbs.mjs +1 -0
- package/src/util/forbidden.mjs +10 -0
- package/src/util/gh.mjs +175 -0
- package/src/worker/engine.mjs +8 -4
- package/src/worker/loop.mjs +42 -0
package/src/session/spawn.mjs
CHANGED
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
* A coder argv that `isForbidden` matches is refused before anything spawns (§8.4). A
|
|
34
34
|
* reviewer/judge/S2/author level on openai is refused too (`closed-book`, B32: Codex always has a
|
|
35
35
|
* shell); an openai fallback step of such a session is skipped with one stderr line. The opt-in
|
|
36
|
-
* `review.allow_open_book_codex: true` lifts both and builds Codex's read-only argv
|
|
36
|
+
* `review.allow_open_book_codex: true` lifts both and builds Codex's read-only argv — except for
|
|
37
|
+
* the autopilot `delegate` (B46), which the opt-in never opens.
|
|
37
38
|
*
|
|
38
39
|
* `background: true` (coders, §5.2): the child is started detached with stdout/stderr going to
|
|
39
40
|
* `<run-root>/sessions/<id>/session.log`, its pid written to `<run-root>/sessions/<id>/pid.json`
|
|
@@ -57,6 +58,8 @@ import { parseSentinel } from '../engines/sentinel.mjs';
|
|
|
57
58
|
import { parseUsage } from '../engines/usage-parse.mjs';
|
|
58
59
|
import { budgetUsdOf, checkBudget, estimateSessionUsd, priceSession, reservedUsd, reserveBudget, SPEND_UNREADABLE_MESSAGE, withBudgetLock } from '../ledger/spend.mjs';
|
|
59
60
|
import { appendRow, readAllRows } from '../ledger/write.mjs';
|
|
61
|
+
import { categoryGate, categoryPlan, RECORD_UNKNOWN_MESSAGE } from '../autopilot/limits.mjs';
|
|
62
|
+
import { StateError } from '../state/paths.mjs';
|
|
60
63
|
import { exec } from '../util/exec.mjs';
|
|
61
64
|
import { isForbidden, mergeForbidden } from '../util/forbidden.mjs';
|
|
62
65
|
import { semaphore } from '../util/locks.mjs';
|
|
@@ -64,7 +67,7 @@ import { readStartTime, registerPid, UNKNOWN_START_TIME } from '../util/reaper.m
|
|
|
64
67
|
import { redact, writeSafe } from '../util/redact.mjs';
|
|
65
68
|
import { currentRunRoot, pidsDir, runRoot, tmpBase, untrustedReason } from '../util/tmp.mjs';
|
|
66
69
|
|
|
67
|
-
export const ROLES = Object.freeze(['coder', 'reviewer', 'judge', 's2', 'author', 'facts']);
|
|
70
|
+
export const ROLES = Object.freeze(['coder', 'reviewer', 'judge', 's2', 'author', 'facts', 'delegate']);
|
|
68
71
|
export const LEVELS = Object.freeze(['L0', 'L1', 'L2', 'L3']);
|
|
69
72
|
|
|
70
73
|
/** Default wall-clock cap of one foreground session. */
|
|
@@ -476,7 +479,7 @@ function childEnv(/** @type {NodeJS.ProcessEnv} */ env) {
|
|
|
476
479
|
* @typedef {object} SessionOpts
|
|
477
480
|
* @property {Record<string, any>} cfg - the loaded project config (levels, provider).
|
|
478
481
|
* @property {"L0"|"L1"|"L2"|"L3"} level
|
|
479
|
-
* @property {"coder"|"reviewer"|"judge"|"s2"|"author"|"facts"} role
|
|
482
|
+
* @property {"coder"|"reviewer"|"judge"|"s2"|"author"|"facts"|"delegate"} role
|
|
480
483
|
* @property {string} promptPath - the brief (coder) or the packet (closed-book).
|
|
481
484
|
* @property {string} [cwd] - coder: the project tree (default `process.cwd()`); facts (B9b.1): an
|
|
482
485
|
* existing read-only snapshot of the project to run in, inside `<runRoot>/facts/` or the
|
|
@@ -507,6 +510,7 @@ function childEnv(/** @type {NodeJS.ProcessEnv} */ env) {
|
|
|
507
510
|
* @property {NodeJS.ProcessEnv} [env]
|
|
508
511
|
* @property {(ms: number) => Promise<unknown>} [sleep] - B40: the rate-limit backoff wait (tests: a recorder).
|
|
509
512
|
* @property {() => number} [random] - B40: the backoff jitter source, in [0, 1) (default `Math.random`).
|
|
513
|
+
* @property {() => Date} [now] - B48: the clock the autopilot grant is checked against (default: now).
|
|
510
514
|
*/
|
|
511
515
|
|
|
512
516
|
/**
|
|
@@ -761,6 +765,14 @@ function sessionUsd(provider, level, usage) {
|
|
|
761
765
|
* cost (`estimateSessionUsd`: the packet's bytes / 4 in, a fixed tokens_out per role and level)
|
|
762
766
|
* run under the run's budget lock, so concurrent sessions of one run see each other's holds. No
|
|
763
767
|
* budget (or no run) ⇒ no lock, no reservation.
|
|
768
|
+
*
|
|
769
|
+
* B48: with an active autopilot grant that caps the session's category (coder → coding, every
|
|
770
|
+
* other role → review), the same lock also runs the category check (`categoryGate`, after the
|
|
771
|
+
* `budget.usd` check): at `stop_at` × cap the session is refused, the grant pauses for the
|
|
772
|
+
* category and one signed `autopilot.pause` row is written; otherwise the estimate is reserved in
|
|
773
|
+
* the category too. The plan is re-read UNDER the lock, so the next session sees the pause the
|
|
774
|
+
* previous one wrote. A run record that cannot be read refuses with reason `autopilot-unknown`.
|
|
775
|
+
* No grant ⇒ exactly the B41 behaviour.
|
|
764
776
|
* @param {SessionOpts} opts @param {SessionDeps} deps
|
|
765
777
|
* @param {((row: Record<string, any>) => Promise<unknown>) | null} writeRow
|
|
766
778
|
* @param {{write: (s: string) => unknown}} stderr
|
|
@@ -769,17 +781,65 @@ function sessionUsd(provider, level, usage) {
|
|
|
769
781
|
* the refusal result, or the (idempotent) release of this attempt's reservation.
|
|
770
782
|
*/
|
|
771
783
|
async function budgetGate(opts, deps, writeRow, stderr, step) {
|
|
772
|
-
const
|
|
773
|
-
if (
|
|
784
|
+
const none = { refused: null, release: () => {} };
|
|
785
|
+
if (typeof opts.run !== 'string' || opts.run.length === 0) return none;
|
|
774
786
|
const run = opts.run;
|
|
787
|
+
const budget = budgetUsdOf(opts.cfg);
|
|
788
|
+
// B48: an active autopilot grant that caps this role's category (null: nothing to meter). Read
|
|
789
|
+
// once here only to decide whether to lock at all, and again under the lock (below).
|
|
790
|
+
const now = (deps.now ?? (() => new Date()))();
|
|
791
|
+
const outside = await readCategoryPlan(run, opts.role, now);
|
|
792
|
+
if (budget === null && outside.plan === null && !outside.unknown) return none;
|
|
775
793
|
const estimate = estimateSessionUsd({ provider: step.provider, level: opts.level, role: opts.role, tokensIn: packetTokens(opts) });
|
|
776
794
|
return withBudgetLock(run, async () => {
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
795
|
+
// under the lock: a fresh clock (the lock may have waited) and a fresh plan, so a pause the
|
|
796
|
+
// previous session wrote is seen here
|
|
797
|
+
const lockedNow = (deps.now ?? (() => new Date()))();
|
|
798
|
+
const { plan, unknown } = await readCategoryPlan(run, opts.role, lockedNow);
|
|
799
|
+
if (unknown) {
|
|
800
|
+
writeSafe(stderr, `code-forge: ${RECORD_UNKNOWN_MESSAGE}\n`);
|
|
801
|
+
return { refused: { status: 'unavailable', reason: 'autopilot-unknown', message: RECORD_UNKNOWN_MESSAGE, answer: null } };
|
|
802
|
+
}
|
|
803
|
+
if (budget !== null) {
|
|
804
|
+
const refused = await checkGate(opts, deps, writeRow, stderr, budget, estimate);
|
|
805
|
+
if (refused) return { refused };
|
|
806
|
+
}
|
|
807
|
+
let releaseCategory = () => {};
|
|
808
|
+
if (plan !== null) {
|
|
809
|
+
const slug = opts.slug ?? plan.project;
|
|
810
|
+
const readRows = deps.readRows ?? (() => readAllRows(slug));
|
|
811
|
+
const gate = await categoryGate({ plan, run, role: opts.role, block: opts.block ?? null, readRows, writeRow, stderr, now: lockedNow, estimate });
|
|
812
|
+
if (gate.refused) return { refused: /** @type {SessionResult} */ (gate.refused) };
|
|
813
|
+
releaseCategory = gate.release;
|
|
814
|
+
}
|
|
815
|
+
const releaseRun = budget !== null ? reserveBudget(run, estimate) : () => {};
|
|
816
|
+
return {
|
|
817
|
+
refused: null,
|
|
818
|
+
release: () => {
|
|
819
|
+
releaseRun();
|
|
820
|
+
releaseCategory();
|
|
821
|
+
},
|
|
822
|
+
};
|
|
780
823
|
});
|
|
781
824
|
}
|
|
782
825
|
|
|
826
|
+
/**
|
|
827
|
+
* B48: the category plan, with a known record-read failure turned into `unknown` (fail closed:
|
|
828
|
+
* a grant we cannot read is never "no grant"); any other error propagates. Accepted on purpose:
|
|
829
|
+
* an unreadable run record refuses the session even for a run without a grant — the record is
|
|
830
|
+
* what says whether a grant exists.
|
|
831
|
+
* @param {string} run @param {string} role @param {Date} now
|
|
832
|
+
* @returns {Promise<{plan: import('../autopilot/limits.mjs').CategoryPlan | null, unknown: boolean}>}
|
|
833
|
+
*/
|
|
834
|
+
async function readCategoryPlan(run, role, now) {
|
|
835
|
+
try {
|
|
836
|
+
return { plan: await categoryPlan(run, role, now), unknown: false };
|
|
837
|
+
} catch (thrown) {
|
|
838
|
+
if (thrown instanceof StateError && thrown.code === 'record-unknown') return { plan: null, unknown: true };
|
|
839
|
+
throw thrown;
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
783
843
|
/**
|
|
784
844
|
* The packet's (or brief's) tokens plus the system prompt's, by the ledger's bytes / 4 rule (an
|
|
785
845
|
* unreadable packet counts 0 bytes; tokens_out is still reserved).
|
package/src/state/run.mjs
CHANGED
|
@@ -205,14 +205,20 @@ export async function startRun(opts) {
|
|
|
205
205
|
* source of truth), then one signed `run.reload` row: the changed key PATHS (never values) and
|
|
206
206
|
* the old/new snapshot hashes. A row that cannot be written leaves the reload in place and is
|
|
207
207
|
* reported as `rowError`.
|
|
208
|
+
*
|
|
209
|
+
* B48 `guard`: called under the lock after the fixed-key check, with the record, the snapshot in
|
|
210
|
+
* force (undefined for a record from before snapshots), the new config and the changed key paths
|
|
211
|
+
* (null without a snapshot); it throws to refuse the reload (nothing written). The autopilot limit
|
|
212
|
+
* guard (`src/autopilot/limits.mjs`) uses it.
|
|
208
213
|
* @param {{
|
|
209
214
|
* runId: string, config: Record<string, any>, readPending?: () => string[], effectiveSlug?: string,
|
|
210
215
|
* writeRow: WriteRow, now?: Date,
|
|
216
|
+
* guard?: (record: Record<string, any>, before: Record<string, any> | undefined, config: Record<string, any>, changed: string[] | null) => void | Promise<void>,
|
|
211
217
|
* }} opts
|
|
212
218
|
* @returns {Promise<{changed: string[] | null, oldHash: string | null, newHash: string, pinned: number, rowError: string | null}>}
|
|
213
219
|
* `changed` is `[]` when nothing changed.
|
|
214
220
|
*/
|
|
215
|
-
export async function reloadRun({ runId, config, readPending = () => [], effectiveSlug, writeRow, now = new Date() }) {
|
|
221
|
+
export async function reloadRun({ runId, config, readPending = () => [], effectiveSlug, writeRow, now = new Date(), guard }) {
|
|
216
222
|
return withRunLock(runId, async () => {
|
|
217
223
|
const current = await readRun(runId);
|
|
218
224
|
if (current.status !== 'active') throw new StateError('run-ended', `run ${runId} has ended`);
|
|
@@ -238,6 +244,7 @@ export async function reloadRun({ runId, config, readPending = () => [], effecti
|
|
|
238
244
|
throw new StateError('immutable-key', `${why.join('; ')} — put ${frozen.length === 1 ? 'it' : 'them'} back, or end the run and start a new one`);
|
|
239
245
|
}
|
|
240
246
|
const changed = state ? changedKeyPaths(before, config) : null;
|
|
247
|
+
if (guard) await guard(current, state ? before : undefined, config, changed);
|
|
241
248
|
/** @type {Record<string, string>} */
|
|
242
249
|
const pins = {};
|
|
243
250
|
const oldPins = state?.pins && typeof state.pins === 'object' ? state.pins : {};
|
package/src/util/breadcrumbs.mjs
CHANGED
|
@@ -25,6 +25,7 @@ import path from 'node:path';
|
|
|
25
25
|
* @type {Readonly<Record<string, readonly string[]>>}
|
|
26
26
|
*/
|
|
27
27
|
export const SUBCOMMANDS = Object.freeze({
|
|
28
|
+
autopilot: ['start', 'status', 'stop'],
|
|
28
29
|
block: ['open', 'attempt', 'rebase', 'claim', 'close', 'stop', 'waive'],
|
|
29
30
|
gates: ['detect', 'run', 'secret-scan', 'safe-edit', 'scope', 'acceptance', 'transcript-grep'],
|
|
30
31
|
jev: ['ask'],
|
package/src/util/forbidden.mjs
CHANGED
|
@@ -276,6 +276,7 @@ export const FORBIDDEN = Object.freeze(
|
|
|
276
276
|
* They are NOT part of `FORBIDDEN` (whose rendered count other blocks pin); `mergeForbidden()`
|
|
277
277
|
* always appends them, so every list built for a coder carries them.
|
|
278
278
|
* - `code-forge block waive` — waiving a review finding is human-only;
|
|
279
|
+
* - `code-forge autopilot` (any subcommand, B45) — the owner's grant to a delegate;
|
|
279
280
|
* - `--no-require-reviews` — the human's way out of `block close`'s per-file review check.
|
|
280
281
|
* @type {ReadonlyArray<ForbiddenEntry>}
|
|
281
282
|
*/
|
|
@@ -290,6 +291,15 @@ export const CODER_ONLY_FORBIDDEN = Object.freeze(
|
|
|
290
291
|
],
|
|
291
292
|
description: 'Waive a review finding (forge block waive) — human-only, run by the orchestrator after the human said so (§4.9)',
|
|
292
293
|
},
|
|
294
|
+
{
|
|
295
|
+
id: 'code-forge-autopilot-from-coder',
|
|
296
|
+
kind: 'anyPrefix',
|
|
297
|
+
prefixes: [
|
|
298
|
+
['code-forge', 'autopilot'],
|
|
299
|
+
['forge', 'autopilot'],
|
|
300
|
+
],
|
|
301
|
+
description: 'Any autopilot command (forge autopilot start / status / stop / …) — the owner grants, reads and ends autopilot, never a coder',
|
|
302
|
+
},
|
|
293
303
|
{
|
|
294
304
|
id: 'code-forge-no-require-reviews',
|
|
295
305
|
kind: 'contains',
|
package/src/util/gh.mjs
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared `gh` helpers (moved out of `cli/logs.mjs`, B27/B28, for B47's tracked waiver issues):
|
|
3
|
+
* the secret check a body must pass before it leaves the machine, a PATH lookup, and the `gh`
|
|
4
|
+
* calls — argv only, never a shell, never gh's output in a message (only `exit n`).
|
|
5
|
+
*
|
|
6
|
+
* - {@link findSecret}: the first secret-shaped text in a body (rule name and line only).
|
|
7
|
+
* - {@link pathLookup}: whether a command is an executable on PATH.
|
|
8
|
+
* - {@link ghRun}: one `gh` call that never throws.
|
|
9
|
+
* - {@link ghWithBodyFile}: a `gh` call whose body goes through a private temp file (removed after).
|
|
10
|
+
* - {@link findReported}: an earlier issue holding a marker in its body.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { accessSync, constants as fsConstants, statSync } from 'node:fs';
|
|
14
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { SECRET_LOOKING_PATTERNS } from '../config/secret-patterns.mjs';
|
|
17
|
+
import { exec as realExec } from './exec.mjs';
|
|
18
|
+
import { currentRunRoot } from './tmp.mjs';
|
|
19
|
+
|
|
20
|
+
/** Every `gh` call gets at most this long. */
|
|
21
|
+
export const GH_TIMEOUT_MS = 60_000;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Secret shapes the final report is checked for, after scrubbing. A hit stops the report.
|
|
25
|
+
* @type {ReadonlyArray<{name: string, re: RegExp}>}
|
|
26
|
+
*/
|
|
27
|
+
export const REPORT_SECRET_RULES = Object.freeze([
|
|
28
|
+
{ name: 'API key (sk-)', re: /(?<![A-Za-z0-9_-])sk-[A-Za-z0-9_-]{10,}/ },
|
|
29
|
+
{ name: 'GitHub token', re: /(?<![A-Za-z0-9_])(?:gh[opsur]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})/ },
|
|
30
|
+
{ name: 'Slack token', re: /(?<![A-Za-z0-9_-])xox[abprs]-[A-Za-z0-9-]{10,}/ },
|
|
31
|
+
{ name: 'AWS access key', re: /(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}(?![0-9A-Z])/ },
|
|
32
|
+
{ name: 'private key', re: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/ },
|
|
33
|
+
{ name: 'JWT', re: /(?<![A-Za-z0-9_-])eyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}/ },
|
|
34
|
+
// copied without g/y: `.test()` must carry no `lastIndex` state between checks
|
|
35
|
+
...SECRET_LOOKING_PATTERNS.map((re) => ({ name: 'secret-shaped token', re: new RegExp(re.source, re.flags.replace(/[gy]/g, '')) })),
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
/** @param {string} s @returns {number} Shannon entropy in bits per character */
|
|
39
|
+
export function entropy(s) {
|
|
40
|
+
/** @type {Map<string, number>} */
|
|
41
|
+
const freq = new Map();
|
|
42
|
+
for (const ch of s) freq.set(ch, (freq.get(ch) ?? 0) + 1);
|
|
43
|
+
let bits = 0;
|
|
44
|
+
for (const n of freq.values()) {
|
|
45
|
+
const p = n / s.length;
|
|
46
|
+
bits -= p * Math.log2(p);
|
|
47
|
+
}
|
|
48
|
+
return bits;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A 32+ character run of `[A-Za-z0-9+/_=-]` with at least two of upper/lower/digit and entropy of
|
|
53
|
+
* at least 4 bits per character. A hex-only run (`0-9a-f`, either case) is never flagged here: a
|
|
54
|
+
* hash using all 16 digits evenly reaches exactly 4 bits and would otherwise trip the rule; the
|
|
55
|
+
* token-shape rules ({@link REPORT_SECRET_RULES}) still apply to it.
|
|
56
|
+
* @param {string} line
|
|
57
|
+
* @returns {boolean}
|
|
58
|
+
*/
|
|
59
|
+
function hasHighEntropyRun(line) {
|
|
60
|
+
for (const m of line.matchAll(/[A-Za-z0-9+/_=-]{32,}/g)) {
|
|
61
|
+
const run = m[0];
|
|
62
|
+
if (/^[0-9a-f]+$/i.test(run)) continue; // a hash, not a token
|
|
63
|
+
const classes = [/[A-Z]/, /[a-z]/, /[0-9]/].filter((re) => re.test(run)).length;
|
|
64
|
+
if (classes >= 2 && entropy(run) >= 4) return true;
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The first secret-shaped text in `text` (rule name and 1-based line), or null. The matched text
|
|
71
|
+
* itself is never returned.
|
|
72
|
+
* @param {string} text
|
|
73
|
+
* @returns {{rule: string, line: number}|null}
|
|
74
|
+
*/
|
|
75
|
+
export function findSecret(text) {
|
|
76
|
+
const lines = text.split('\n');
|
|
77
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
78
|
+
for (const r of REPORT_SECRET_RULES) if (r.re.test(lines[i])) return { rule: r.name, line: i + 1 };
|
|
79
|
+
if (hasHighEntropyRun(lines[i])) return { rule: 'high-entropy string', line: i + 1 };
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** @param {string} pathEnv @returns {(cmd: string) => boolean} */
|
|
85
|
+
export function pathLookup(pathEnv) {
|
|
86
|
+
return (cmd) =>
|
|
87
|
+
pathEnv.split(path.delimiter).some((dir) => {
|
|
88
|
+
if (!dir) return false;
|
|
89
|
+
try {
|
|
90
|
+
const f = path.join(dir, cmd);
|
|
91
|
+
accessSync(f, fsConstants.X_OK);
|
|
92
|
+
return statSync(f).isFile();
|
|
93
|
+
} catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* One `gh` call; a throw from `exec` is a `failed` result with no output.
|
|
101
|
+
* @param {typeof realExec} exec @param {string[]} argv @param {NodeJS.ProcessEnv} env
|
|
102
|
+
* @param {{cwd?: string}} [opts]
|
|
103
|
+
* @returns {Promise<{result: string, code: number|null, timedOut: boolean, stdout: string, stderr: string}>}
|
|
104
|
+
*/
|
|
105
|
+
export async function ghRun(exec, argv, env, opts = {}) {
|
|
106
|
+
try {
|
|
107
|
+
return await exec(argv, { env, timeoutMs: GH_TIMEOUT_MS, ...(opts.cwd ? { cwd: opts.cwd } : {}) });
|
|
108
|
+
} catch {
|
|
109
|
+
return { result: 'failed', code: null, timedOut: false, stdout: '', stderr: '' };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Write `text` to a private temp file and run the first argv of `argvsFor(file)`; when it fails
|
|
115
|
+
* over a label, run the next one (once).
|
|
116
|
+
* @param {typeof realExec} exec @param {NodeJS.ProcessEnv} env @param {string} text
|
|
117
|
+
* @param {(file: string) => string[][]} argvsFor
|
|
118
|
+
* @param {(stdout: string) => void} [onOk]
|
|
119
|
+
* @returns {Promise<string|null>} null when gh succeeded; else why (`exit n`, never its output).
|
|
120
|
+
*/
|
|
121
|
+
export async function ghWithBodyFile(exec, env, text, argvsFor, onOk) {
|
|
122
|
+
/** @type {string|null} */
|
|
123
|
+
let dir = null;
|
|
124
|
+
try {
|
|
125
|
+
let res;
|
|
126
|
+
try {
|
|
127
|
+
const root = currentRunRoot();
|
|
128
|
+
await mkdir(root, { recursive: true });
|
|
129
|
+
dir = await mkdtemp(path.join(root, 'report-'));
|
|
130
|
+
const bodyFile = path.join(dir, 'body.md');
|
|
131
|
+
await writeFile(bodyFile, text, { encoding: 'utf8', mode: 0o600 });
|
|
132
|
+
const [first, retry] = argvsFor(bodyFile);
|
|
133
|
+
res = await ghRun(exec, first, env);
|
|
134
|
+
// gh's stderr is matched to decide the retry, never printed
|
|
135
|
+
if (res.result !== 'ok' && retry && /label/i.test(res.stderr)) res = await ghRun(exec, retry, env);
|
|
136
|
+
} catch {
|
|
137
|
+
return 'could not write the report file';
|
|
138
|
+
}
|
|
139
|
+
if (res.result !== 'ok') return res.code !== null ? `exit ${res.code}` : res.timedOut ? 'timed out' : 'did not finish';
|
|
140
|
+
onOk?.(res.stdout);
|
|
141
|
+
return null;
|
|
142
|
+
} finally {
|
|
143
|
+
if (dir !== null) await rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* An earlier issue holding `needle` in its body (open or closed): `{hit}` (null when there is
|
|
149
|
+
* none), or `{why}` when the search failed (`gh exit n`; never gh's output). With `opts.match`
|
|
150
|
+
* the bodies are fetched too and a hit must contain that exact text (GitHub's search is fuzzy);
|
|
151
|
+
* then a full page (`opts.limit` results, default 5) with no exact hit is `{why: 'too-many-results'}`
|
|
152
|
+
* — the match may be on a page not read, so the caller must not assume there is none.
|
|
153
|
+
* @param {typeof realExec} exec @param {NodeJS.ProcessEnv} env @param {string} repo @param {string} needle
|
|
154
|
+
* @param {{match?: string, limit?: number}} [opts]
|
|
155
|
+
* @returns {Promise<{hit: {number: number, url: string, state: string}|null} | {why: string}>}
|
|
156
|
+
*/
|
|
157
|
+
export async function findReported(exec, env, repo, needle, opts = {}) {
|
|
158
|
+
const fields = opts.match === undefined ? 'number,url,state' : 'number,url,state,body';
|
|
159
|
+
const limit = Number.isInteger(opts.limit) && /** @type {number} */ (opts.limit) > 0 ? /** @type {number} */ (opts.limit) : 5;
|
|
160
|
+
const res = await ghRun(exec, ['gh', 'issue', 'list', '--repo', repo, '--state', 'all', '--search', `${needle} in:body`, '--json', fields, '--limit', String(limit)], env);
|
|
161
|
+
if (res.result !== 'ok') return { why: res.code !== null ? `gh exit ${res.code}` : res.timedOut ? 'gh timed out' : 'gh did not finish' };
|
|
162
|
+
let list;
|
|
163
|
+
try {
|
|
164
|
+
list = JSON.parse(res.stdout);
|
|
165
|
+
} catch {
|
|
166
|
+
return { why: 'gh gave no list' };
|
|
167
|
+
}
|
|
168
|
+
if (!Array.isArray(list)) return { why: 'gh gave no list' };
|
|
169
|
+
const match = opts.match;
|
|
170
|
+
const hit = list.find(
|
|
171
|
+
(i) => Number.isSafeInteger(i?.number) && typeof i?.url === 'string' && /^https:\/\//.test(i.url) && (match === undefined || (typeof i.body === 'string' && i.body.includes(match))),
|
|
172
|
+
);
|
|
173
|
+
if (!hit && match !== undefined && list.length >= limit) return { why: 'too-many-results' };
|
|
174
|
+
return { hit: hit ? { number: hit.number, url: hit.url, state: typeof hit.state === 'string' ? hit.state.toLowerCase() : 'unknown' } : null };
|
|
175
|
+
}
|
package/src/worker/engine.mjs
CHANGED
|
@@ -42,7 +42,7 @@ import { planAndRecord, tierOf } from '../review/budget.mjs';
|
|
|
42
42
|
import { budgetFor } from '../review/packet.mjs';
|
|
43
43
|
import { reviewFile, rulesRisk } from '../review/engine.mjs';
|
|
44
44
|
import { computeFileSet, ownsFile } from '../gates/scope.mjs';
|
|
45
|
-
import { newFileState, runRound } from '../review/fixloop.mjs';
|
|
45
|
+
import { extraRoundsFor, newFileState, reopenForExtraRound, runRound } from '../review/fixloop.mjs';
|
|
46
46
|
import { readRun } from '../state/run.mjs';
|
|
47
47
|
import { redact } from '../util/redact.mjs';
|
|
48
48
|
import { keyedLock } from '../util/locks.mjs';
|
|
@@ -297,6 +297,9 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, blockKindOf, key,
|
|
|
297
297
|
let state = loaded.state;
|
|
298
298
|
if (state === null || state.status === 'complete') state = newFileState({ file: ticket.file, level, l3RungUsed: rungUsed });
|
|
299
299
|
state.l3_rung_used = state.l3_rung_used || rungUsed;
|
|
300
|
+
// B47: an autopilot extra round raises the file's cap; a file stopped at the old cap reopens
|
|
301
|
+
const extraRounds = extraRoundsFor(rows, { runId: ctx.runId, block: ticket.block, file: ticket.file, key });
|
|
302
|
+
reopenForExtraRound(state, extraRounds, ctx.cfg);
|
|
300
303
|
|
|
301
304
|
if (state.status === 'stopped') {
|
|
302
305
|
return { outcome: /** @type {ReviewOutcome} */ ({ ...loopFields(state, null), status: 'stopped', stopped: state.next?.reason ?? 'stopped', approved: false, engine: 'adaptive', sessions: [], findings: state.open }) };
|
|
@@ -310,7 +313,7 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, blockKindOf, key,
|
|
|
310
313
|
state.l3_rung_used = true; // the orchestrator ran the block's one L3 patch before this ticket
|
|
311
314
|
}
|
|
312
315
|
const fixed = state;
|
|
313
|
-
const round = () => playRound(ticket, ctx, { state: fixed, kind, where, seq: loaded.seq, base, blockKindOf, workDir, factsExcerpt });
|
|
316
|
+
const round = () => playRound(ticket, ctx, { state: fixed, kind, where, seq: loaded.seq, base, blockKindOf, workDir, factsExcerpt, extraRounds });
|
|
314
317
|
// a round that could take the rung keeps the block lock until its state is saved
|
|
315
318
|
if (mayTakeRung(fixed, kind, ctx.cfg, rungUsed)) return { outcome: await round() };
|
|
316
319
|
return { round };
|
|
@@ -321,10 +324,10 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, blockKindOf, key,
|
|
|
321
324
|
/**
|
|
322
325
|
* Run the prepared round and save the file's state: the ticket's outcome.
|
|
323
326
|
* @param {import('./queue.mjs').Ticket} ticket @param {ReviewContext} ctx
|
|
324
|
-
* @param {{state: import('../review/fixloop.mjs').FileState, kind: 'full' | 'recheck' | 'patch_check', where: import('./review-state.mjs').Where, seq: number, base: string | null, blockKindOf: string, workDir: string, factsExcerpt: string}} opts
|
|
327
|
+
* @param {{state: import('../review/fixloop.mjs').FileState, kind: 'full' | 'recheck' | 'patch_check', where: import('./review-state.mjs').Where, seq: number, base: string | null, blockKindOf: string, workDir: string, factsExcerpt: string, extraRounds?: number}} opts
|
|
325
328
|
* @returns {Promise<ReviewOutcome>}
|
|
326
329
|
*/
|
|
327
|
-
async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKindOf, workDir, factsExcerpt }) {
|
|
330
|
+
async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKindOf, workDir, factsExcerpt, extraRounds = 0 }) {
|
|
328
331
|
/** @type {Record<string, any> | null} */
|
|
329
332
|
let engineOutcome = null;
|
|
330
333
|
let approvalWritten = false;
|
|
@@ -356,6 +359,7 @@ async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKind
|
|
|
356
359
|
base,
|
|
357
360
|
writeRow,
|
|
358
361
|
factsExcerpt,
|
|
362
|
+
extraRounds,
|
|
359
363
|
...(jev ? { jev } : {}),
|
|
360
364
|
review: async () => {
|
|
361
365
|
engineOutcome = await reviewFile({ repoRoot: ctx.repoRoot, file: ticket.file, base, cfg: ctx.cfg, kind: blockKindOf, workDir, factsExcerpt }, { spawn: ctx.spawn, writeRow });
|
package/src/worker/loop.mjs
CHANGED
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
import { spawn as nodeSpawn } from 'node:child_process';
|
|
38
38
|
import path from 'node:path';
|
|
39
39
|
import { fileURLToPath } from 'node:url';
|
|
40
|
+
import { expiryChecks } from '../autopilot/limits.mjs';
|
|
40
41
|
import { loadProjectConfig } from '../config/load.mjs';
|
|
41
42
|
import { createDefaultKeyStore, resolveKey } from '../keys/store.mjs';
|
|
42
43
|
import { appendRow, readAllRows } from '../ledger/write.mjs';
|
|
@@ -44,7 +45,9 @@ import { spawnSession } from '../session/spawn.mjs';
|
|
|
44
45
|
import { snapshotFor } from '../state/config-snapshot.mjs';
|
|
45
46
|
import { readRun, reattachWorker } from '../state/run.mjs';
|
|
46
47
|
import { loadKey, signRow } from '../state/signer.mjs';
|
|
48
|
+
import { logWarning } from '../util/error-log.mjs';
|
|
47
49
|
import { keyedLock } from '../util/locks.mjs';
|
|
50
|
+
import { writeSafe } from '../util/redact.mjs';
|
|
48
51
|
import { runRoot, setRunRoot } from '../util/tmp.mjs';
|
|
49
52
|
import { reviewTicket } from './engine.mjs';
|
|
50
53
|
import { announceWorker, beatWorker, HEARTBEAT_MS, liveWorker, pendingTickets, readTicket, retractWorker, writeResult } from './queue.mjs';
|
|
@@ -58,6 +61,24 @@ export const DEFAULT_SESSION_TIMEOUT_S = 300;
|
|
|
58
61
|
|
|
59
62
|
/** B42: `review.parallel_tickets` when the config does not set a valid one. */
|
|
60
63
|
export const DEFAULT_PARALLEL_TICKETS = 3;
|
|
64
|
+
|
|
65
|
+
/** B48: the fixed text of a failed autopilot expiry check in a drain (stderr and the warning log). */
|
|
66
|
+
export const EXPIRY_CHECK_FAILED = 'the autopilot expiry check failed in a worker drain; it runs again at the next drain';
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* B48: what kind of error an expiry check threw — its code or class only, NEVER its message (a
|
|
70
|
+
* message can carry a path or a value): `EACCES`, `StateError:record-unknown`, `TypeError`.
|
|
71
|
+
* Anything that does not look like a plain identifier is `Error`.
|
|
72
|
+
* @param {unknown} thrown @returns {string}
|
|
73
|
+
*/
|
|
74
|
+
export function errorKind(thrown) {
|
|
75
|
+
if (!(thrown instanceof Error)) return 'Error';
|
|
76
|
+
const err = /** @type {any} */ (thrown);
|
|
77
|
+
const name = typeof err.name === 'string' ? err.name : 'Error';
|
|
78
|
+
const code = typeof err?.code === 'string' ? err.code : null;
|
|
79
|
+
const kind = code === null ? name : /^E[A-Z]+$/.test(code) ? code : `${name}:${code}`;
|
|
80
|
+
return /^[A-Za-z0-9_:.-]{1,64}$/.test(kind) ? kind : 'Error';
|
|
81
|
+
}
|
|
61
82
|
export const MAX_PARALLEL_TICKETS = 16;
|
|
62
83
|
|
|
63
84
|
/**
|
|
@@ -166,6 +187,10 @@ export function loopResult(outcome) {
|
|
|
166
187
|
* @property {{write: (s: string) => unknown}} [stderr]
|
|
167
188
|
* @property {import('../review/triage.mjs').JevAsk} [jev] - S1 for triage and the recheck (tests
|
|
168
189
|
* inject a mock); default: `askJev` with the resolved Jev key, none without one.
|
|
190
|
+
* @property {() => Promise<unknown>} [expiryCheck] - B48: the lazy autopilot checks, run once at the
|
|
191
|
+
* start of every drain, before any ticket; default: `expiryChecks(opts.runId, <now>, {writeRow})`
|
|
192
|
+
* (grant expiry, expired approvals). A failure is never silent: one stderr line and one
|
|
193
|
+
* `autopilot_expiry_check_failed` warning per streak of failures; the next drain tries again.
|
|
169
194
|
*/
|
|
170
195
|
|
|
171
196
|
/**
|
|
@@ -180,6 +205,10 @@ export async function createWorker(opts, deps = {}) {
|
|
|
180
205
|
const loadTicket = deps.readTicket ?? readTicket;
|
|
181
206
|
const writeRow = deps.writeRow ?? ((/** @type {Record<string, any>} */ row) => appendRow(row, { slug }));
|
|
182
207
|
const readRows = deps.readRows ?? (() => readAllRows(slug));
|
|
208
|
+
// B48: the run's own writer (unsigned base; the checks sign their rows with the run key)
|
|
209
|
+
const expiryCheck = deps.expiryCheck ?? (() => expiryChecks(runId, new Date(), { writeRow }));
|
|
210
|
+
const warnStream = deps.stderr ?? process.stderr;
|
|
211
|
+
let expiryFailing = false; // inside a streak of failed checks: warn once per streak
|
|
183
212
|
const store = deps.store ?? (await createDefaultKeyStore(env));
|
|
184
213
|
const jevKey = await resolveJevKey(cfg, { store, env, opRead: deps.opRead });
|
|
185
214
|
const childEnv = sessionEnv(env, [jevKey, key.toString('hex')]);
|
|
@@ -420,6 +449,19 @@ export async function createWorker(opts, deps = {}) {
|
|
|
420
449
|
* @returns {Promise<number>} how many were completed.
|
|
421
450
|
*/
|
|
422
451
|
async function drain() {
|
|
452
|
+
// B48: the lazy autopilot checks (grant expiry, expired approvals) before any ticket starts;
|
|
453
|
+
// a failed check never stops the drain (the next drain tries again) and is never silent
|
|
454
|
+
try {
|
|
455
|
+
await expiryCheck();
|
|
456
|
+
expiryFailing = false;
|
|
457
|
+
} catch (thrown) {
|
|
458
|
+
if (!expiryFailing) {
|
|
459
|
+
expiryFailing = true;
|
|
460
|
+
const text = `${EXPIRY_CHECK_FAILED} (${errorKind(thrown)})`;
|
|
461
|
+
writeSafe(warnStream, `worker: WARN ${text}\n`);
|
|
462
|
+
await logWarning({ warning: 'autopilot_expiry_check_failed', message: text }).catch(() => null);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
423
465
|
/** @type {number | null} */
|
|
424
466
|
let limit = null;
|
|
425
467
|
let done = 0;
|