@codedology/code-forge 0.14.1 → 0.15.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 +29 -0
- package/README.md +1 -1
- package/docs/reference/blocks.json +495 -1
- package/docs/reference/config.md +7 -0
- package/package.json +1 -1
- package/schema/code-forge.schema.json +22 -0
- package/scripts/first-use.mjs +11 -5
- package/skill/SKILL.md +4 -0
- package/skill/references/autopilot.md +2 -0
- package/skill/references/code.md +28 -2
- package/skill/references/review.md +10 -2
- package/skill/references/security.md +2 -0
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +3 -1
- package/src/autopilot/scopes.mjs +2 -1
- package/src/cli/block.mjs +271 -50
- package/src/cli/gates.mjs +36 -6
- package/src/cli/ledger.mjs +21 -2
- package/src/cli/report.mjs +14 -0
- package/src/cli/review-file.mjs +56 -16
- package/src/cli/run.mjs +44 -7
- package/src/cli/status.mjs +7 -3
- package/src/config/validate.mjs +13 -1
- package/src/gates/acceptance.mjs +7 -3
- package/src/gates/expand.mjs +25 -2
- package/src/gates/run.mjs +38 -8
- package/src/ledger/report.mjs +29 -7
- package/src/review/accept-hit.mjs +22 -4
- package/src/review/engine.mjs +30 -10
- package/src/review/finding.schema.json +2 -1
- package/src/review/fixloop.mjs +79 -14
- package/src/review/gate-check.mjs +33 -7
- package/src/review/lenses/recheck.md +3 -0
- package/src/review/packet.mjs +26 -0
- package/src/state/block.mjs +196 -22
- package/src/state/run.mjs +35 -8
- package/src/util/forbidden.mjs +230 -6
- package/src/util/reaper.mjs +3 -3
- package/src/worker/engine.mjs +74 -6
- package/src/worker/loop.mjs +19 -10
- package/src/worker/queue.mjs +38 -8
|
@@ -139,7 +139,7 @@ export function checkBlockReviews({ block, runId, files, rows, key, transcript =
|
|
|
139
139
|
/** @type {Map<string, string>} */
|
|
140
140
|
const current = new Map();
|
|
141
141
|
for (const f of [...proofFiles, ...files]) current.set(f.file, f.content_hash);
|
|
142
|
-
const { mine, latestOf, signedOf, matches, waiverOf, waiverFor, waivedAllOf, recordedRefusalSeverity, supersededCap } = reviewRules({ block, runId, rows, key, resultFiles, current });
|
|
142
|
+
const { mine, acceptanceAmended, latestOf, signedOf, matches, waiverOf, waiverFor, waivedAllOf, recordedRefusalSeverity, supersededCap } = reviewRules({ block, runId, rows, key, resultFiles, current });
|
|
143
143
|
/** @type {Refusal[]} */
|
|
144
144
|
const refusals = [];
|
|
145
145
|
const approvedFor = (/** @type {string} */ file, /** @type {string} */ hash) => mine.filter((r) => r.event === 'review.approved' && r.file === file && r.content_hash === hash);
|
|
@@ -169,7 +169,7 @@ export function checkBlockReviews({ block, runId, files, rows, key, transcript =
|
|
|
169
169
|
else {
|
|
170
170
|
// B73: a stopped file (a cap row of the block) names its ways out
|
|
171
171
|
const exits = mine.some((r) => r.event === 'review.cap' && r.file === file) ? `; ${stopExitsLine}` : '';
|
|
172
|
-
refusals.push({ code: 'unreviewed', file, detail: `no review.approved row for ${file} at its current content${exits}` });
|
|
172
|
+
refusals.push({ code: 'unreviewed', file, detail: `no review.approved row for ${file} at its current content${acceptanceAmended ? ' (acceptance amended)' : ''}${exits}` });
|
|
173
173
|
}
|
|
174
174
|
} else if (!signedApproval) refusals.push({ code: 'unsigned', file, detail: `the review.approved row for ${file} has no valid MAC` });
|
|
175
175
|
if (signedApproval) {
|
|
@@ -237,6 +237,24 @@ export function checkBlockReviews({ block, runId, files, rows, key, transcript =
|
|
|
237
237
|
return { ok: refusals.length === 0, refusals, waivedAll: refusals.length === 0 ? waivedAll : [], ...(acceptedHits.length > 0 ? { acceptedHits } : {}) };
|
|
238
238
|
}
|
|
239
239
|
|
|
240
|
+
/** B138: delegation never turns a waived or answered file into an approval.
|
|
241
|
+
* @param {Parameters<typeof checkBlockReviews>[0]} opts
|
|
242
|
+
* @returns {string | null}
|
|
243
|
+
*/
|
|
244
|
+
export function routineCloseProblem(opts) {
|
|
245
|
+
const mine = opts.rows.filter((r) => r.block === opts.block && r.run === opts.runId);
|
|
246
|
+
if (mine.some((r) => typeof r.event === 'string' && r.event.includes('waiv'))) return 'waiver rows for block';
|
|
247
|
+
if (mine.some((r) => r.event === 'rule_break' || r.event === 'rule_break.accepted')) return 'rule_break or accepted hit for block';
|
|
248
|
+
const hits = acceptedTranscriptHits({ ...opts, transcript: opts.transcript ?? null, rows: [] }).remaining;
|
|
249
|
+
if (hits.length > 0) return `rule_break transcript line ${hits[0].line}: ${hits[0].id}`;
|
|
250
|
+
if (!mine.some((r) => r.event === 'gate.done' && r.all_ok === true && verified(r, opts.key))) return 'no signed green gate row';
|
|
251
|
+
const result = checkBlockReviews(opts);
|
|
252
|
+
if (!result.ok) return result.refusals.map((r) => `${r.code}${r.file ? ` ${r.file}` : ''}`).join('; ');
|
|
253
|
+
if (result.waivedAll.length > 0) return 'reviewed_by_waiver or answered-only file';
|
|
254
|
+
if (opts.files.length === 0) return 'no approved changed files';
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
|
|
240
258
|
/** B71: the signed row the worker writes for a finding a reviewer judged resolved by another owned file. */
|
|
241
259
|
export const ANSWERED_EVENT = 'review.answered';
|
|
242
260
|
|
|
@@ -261,7 +279,14 @@ function reviewRules({ block, runId, rows, key, resultFiles = {}, current, verif
|
|
|
261
279
|
// B71: `verify` replaces the MAC check only for `review-file`'s unverified pre-check ({@link
|
|
262
280
|
// namedOnHash}), which has no key and can only refuse; it then reads no autopilot waiver
|
|
263
281
|
const ok = verify ?? ((/** @type {Record<string, any>} */ r) => verified(r, key));
|
|
264
|
-
|
|
282
|
+
let amendedAt = -1;
|
|
283
|
+
rows.forEach((r, i) => {
|
|
284
|
+
if (r?.event === 'block.acceptance_amended' && r.block === block && r.run === runId && ok(r)) amendedAt = i;
|
|
285
|
+
});
|
|
286
|
+
const acceptanceAmended = amendedAt >= 0;
|
|
287
|
+
// Acceptance is a review input. Every older review verdict belongs to the previous input.
|
|
288
|
+
const mine = rows.filter((r, i) => r?.block === block && (runId === undefined || r.run === undefined || r.run === runId)
|
|
289
|
+
&& !(i < amendedAt && typeof r.event === 'string' && r.event.startsWith('review.')));
|
|
265
290
|
/** A verified human or agreed agent reopen followed by a real review clears older cap ids.
|
|
266
291
|
* @param {Record<string, any>} cap @returns {boolean}
|
|
267
292
|
*/
|
|
@@ -499,7 +524,7 @@ function reviewRules({ block, runId, rows, key, resultFiles = {}, current, verif
|
|
|
499
524
|
return new Map([...named].filter(([id, severity]) => id !== PROOF_FINDING && answerableSeverity(severity) && waiverOf(file, id, severity, ticket).by === null));
|
|
500
525
|
};
|
|
501
526
|
|
|
502
|
-
return { mine, latestOf, signedOf, matches, waiverOf, waiverFor, waivedAllOf, openAt, answerRows, recordedRefusalSeverity, supersededCap };
|
|
527
|
+
return { mine, acceptanceAmended, latestOf, signedOf, matches, waiverOf, waiverFor, waivedAllOf, openAt, answerRows, recordedRefusalSeverity, supersededCap };
|
|
503
528
|
}
|
|
504
529
|
|
|
505
530
|
/**
|
|
@@ -639,15 +664,16 @@ export function openFindings(findings) {
|
|
|
639
664
|
* B52: the signed result files {@link denialOf} needs — one per `review.result` row of the block
|
|
640
665
|
* that has no `approved` field (an old ledger) and is a verdict — keyed by ticket. A file that is
|
|
641
666
|
* missing, unparsable, names another run or ticket, or fails its MAC is null.
|
|
642
|
-
* @param {{repoRoot: string, runId: string, block: string, rows: Array<Record<string, any
|
|
667
|
+
* @param {{repoRoot: string, runId: string, block: string, rows: Array<Record<string, any>>, includeCurrent?: boolean}} opts
|
|
643
668
|
* `repoRoot`: the repository root the worker writes `.code-forge/reviews/` under.
|
|
669
|
+
* `includeCurrent`: B138 decision lists also need claims from modern signed results.
|
|
644
670
|
* @returns {Promise<Record<string, ResultFile>>}
|
|
645
671
|
*/
|
|
646
|
-
export async function resultFilesFor({ repoRoot, runId, block, rows }) {
|
|
672
|
+
export async function resultFilesFor({ repoRoot, runId, block, rows, includeCurrent = false }) {
|
|
647
673
|
/** @type {Record<string, ResultFile>} */
|
|
648
674
|
const out = {};
|
|
649
675
|
for (const r of rows) {
|
|
650
|
-
if (r?.event !== 'review.result' || r.block !== block || Object.hasOwn(r, 'approved') || neverReviewed(r) || typeof r.ticket !== 'string' || Object.hasOwn(out, r.ticket)) continue;
|
|
676
|
+
if (r?.event !== 'review.result' || r.block !== block || (!includeCurrent && Object.hasOwn(r, 'approved')) || neverReviewed(r) || typeof r.ticket !== 'string' || Object.hasOwn(out, r.ticket)) continue;
|
|
651
677
|
try {
|
|
652
678
|
const checked = await verifyResult(repoRoot, runId, r.ticket);
|
|
653
679
|
out[r.ticket] = checked.ok && checked.result ? { status: checked.result.status, approved: checked.result.approved, findings: checked.result.findings } : null;
|
|
@@ -10,6 +10,9 @@ that changed since that round, each with a window of the current file around it.
|
|
|
10
10
|
- For every open finding, add one entry to `resolved`: `{id, resolved: true|false, why}`
|
|
11
11
|
(`why` ≤ 200 chars). `resolved: true` only when the shown lines fix the problem without
|
|
12
12
|
introducing a new one.
|
|
13
|
+
- For `resolved: false`, you MUST add `current_lines` (≤ 300 chars): quote the exact current
|
|
14
|
+
line(s) from the packet that still show the problem, without line-number prefixes. Read the
|
|
15
|
+
changed lines; the old finding's claim and evidence alone cannot keep it open.
|
|
13
16
|
- A new problem goes in `findings` ONLY when its `line_start..line_end` lies inside a shown fix
|
|
14
17
|
hunk. Anything outside the hunks is not yours to report in this round.
|
|
15
18
|
- `passed` is true only when every open finding is resolved and you raised no new `critical`
|
package/src/review/packet.mjs
CHANGED
|
@@ -223,6 +223,32 @@ export function assemblePacket({ diff, lens, rulesDigest = '', factsExcerpt = ''
|
|
|
223
223
|
return { status: 'ok', text, hunkHeaders, contextMode: ctx.mode, contextLines: ctx.lineCount, digestLines: digest.length, tokensIn, budget: limit, overBudget: tokensIn > limit };
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
+
/** One full-budget packet for a light block; every diff and path heading is retained.
|
|
227
|
+
* @param {{diffs: FileDiff[], cfg?: Record<string, any>, rulesDigest?: string, factsExcerpt?: string}} opts
|
|
228
|
+
*/
|
|
229
|
+
export function assembleLightPacket({ diffs, cfg, rulesDigest = '', factsExcerpt = '' }) {
|
|
230
|
+
const lens = `${loadLens('full')}\nReview ALL files below in one round. Findings must name their exact file path. Echo every listed hunk in file order, including duplicates.`;
|
|
231
|
+
const diffText = diffs.map((diff) => `### ${diff.file}\n${diffSectionOf(diff)}`).join('\n\n');
|
|
232
|
+
const budget = budgetFor(cfg, 'full_in');
|
|
233
|
+
let digest = splitLines(rulesDigest).slice(0, DIGEST_MAX_LINES);
|
|
234
|
+
const context = (mode) => diffs.map((diff) => `### ${diff.file}\n${buildContext({ file: diff.file, content: diff.content, hunks: diff.hunks, cfg, mode }).text}`).join('\n\n');
|
|
235
|
+
let contextMode = 'auto';
|
|
236
|
+
let ctx = context(contextMode);
|
|
237
|
+
let text = render(lens, digest, factsExcerpt, ctx, diffText);
|
|
238
|
+
if (estimateTokens(text) > budget) {
|
|
239
|
+
contextMode = 'minimal';
|
|
240
|
+
ctx = context(contextMode);
|
|
241
|
+
text = render(lens, digest, factsExcerpt, ctx, diffText);
|
|
242
|
+
}
|
|
243
|
+
while (estimateTokens(text) > budget && digest.length > 0) {
|
|
244
|
+
digest = digest.slice(0, -1);
|
|
245
|
+
text = render(lens, digest, factsExcerpt, ctx, diffText);
|
|
246
|
+
}
|
|
247
|
+
const tokensIn = estimateTokens(text);
|
|
248
|
+
return { status: tokensIn > budget ? 'split_required' : 'ok', text, tokensIn, budget,
|
|
249
|
+
contextMode, hunkHeaders: diffs.flatMap((diff) => diff.hunks.map((h) => h.header)) };
|
|
250
|
+
}
|
|
251
|
+
|
|
226
252
|
/** Cap the entire section at full_in / 16 tokens, without cutting a UTF-8 character.
|
|
227
253
|
* @param {string} paired @param {number} fullIn @returns {string}
|
|
228
254
|
*/
|
package/src/state/block.mjs
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { createHash, randomUUID } from 'node:crypto';
|
|
15
|
-
import { createReadStream, existsSync, lstatSync, readlinkSync, realpathSync } from 'node:fs';
|
|
15
|
+
import { createReadStream, existsSync, lstatSync, readFileSync, readdirSync, readlinkSync, realpathSync } from 'node:fs';
|
|
16
16
|
import os from 'node:os';
|
|
17
17
|
import path from 'node:path';
|
|
18
18
|
import { BLOCK_KINDS, blockKind, dispatchLevel } from '../decide/escalation.mjs';
|
|
@@ -24,14 +24,16 @@ 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 { verifyRow, loadKey } from './signer.mjs';
|
|
27
|
+
import { canonicalJSON, verifyRow, loadKey } from './signer.mjs';
|
|
28
|
+
import * as reviewQueue from '../worker/queue.mjs';
|
|
29
|
+
import { repoRootOf } from '../worker/ticket.mjs';
|
|
28
30
|
|
|
29
31
|
/** @typedef {import('./run.mjs').WriteRow} WriteRow */
|
|
30
32
|
|
|
31
33
|
export const BRIEF_POINTER_MAX_BYTES = 200;
|
|
32
34
|
|
|
33
35
|
/** Ledger events the block gate requires to carry a valid MAC (§4.6 row 10). */
|
|
34
|
-
export const GATE_EVENTS = Object.freeze(['dispatch', 'block.attempt', 'block.rebase', 'block.claim', 'review.approved', 'proof']);
|
|
36
|
+
export const GATE_EVENTS = Object.freeze(['dispatch', 'block.attempt', 'block.rebase', 'block.claim', 'block.acceptance_amended', 'review.approved', 'proof']);
|
|
35
37
|
|
|
36
38
|
const BLOCK_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/;
|
|
37
39
|
const LEVEL = /^L[0-3]$/;
|
|
@@ -216,19 +218,53 @@ export function briefPointer(relPath, content) {
|
|
|
216
218
|
|
|
217
219
|
/**
|
|
218
220
|
* @param {unknown} clauses
|
|
219
|
-
* @returns {
|
|
221
|
+
* @returns {import('../gates/acceptance.mjs').AcceptanceClause[]}
|
|
220
222
|
*/
|
|
221
223
|
function assertAcceptance(clauses) {
|
|
222
224
|
const ok =
|
|
223
225
|
Array.isArray(clauses) &&
|
|
224
226
|
clauses.length > 0 &&
|
|
225
227
|
clauses.every(
|
|
226
|
-
(c) => typeof c?.clause === 'string' && c.clause.length > 0 &&
|
|
228
|
+
(c) => typeof c?.clause === 'string' && c.clause.length > 0 && (c.kind === 'human_check'
|
|
229
|
+
? !Object.hasOwn(c, 'tests')
|
|
230
|
+
: Array.isArray(c.tests) && c.tests.length > 0 && c.tests.every((t) => typeof t === 'string' && t.length > 0)),
|
|
227
231
|
);
|
|
228
|
-
if (!ok) throw new StateError('bad-acceptance', 'acceptance: a non-empty list of {clause, tests: [test ids…]} is required');
|
|
232
|
+
if (!ok) throw new StateError('bad-acceptance', 'acceptance: a non-empty list of {clause, tests: [test ids…]} or {clause, kind: human_check} without tests is required');
|
|
229
233
|
return clauses;
|
|
230
234
|
}
|
|
231
235
|
|
|
236
|
+
/** Validate before the human question, and again under the run lock.
|
|
237
|
+
* @param {Record<string, any>} record @param {string} id @param {unknown} clauses
|
|
238
|
+
*/
|
|
239
|
+
export function prepareAcceptanceAmendment(record, id, clauses) {
|
|
240
|
+
const block = record.blocks?.[id];
|
|
241
|
+
if (!block) throw new StateError('unknown_block', `block ${id} is not in run ${record.run_id}`);
|
|
242
|
+
if (block.status !== 'open') throw new StateError('not-open', `not-open: block ${id} is not open`);
|
|
243
|
+
const acceptance = assertAcceptance(clauses);
|
|
244
|
+
const old_sha256 = sha256(canonicalJSON(block.acceptance));
|
|
245
|
+
const new_sha256 = sha256(canonicalJSON(acceptance));
|
|
246
|
+
if (old_sha256 === new_sha256) throw new StateError('acceptance-unchanged', 'acceptance-unchanged: the clause list is unchanged; nothing was recorded');
|
|
247
|
+
return { acceptance, old_sha256, new_sha256, oldCount: block.acceptance.length };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** @param {{runId: string, id: string, acceptance: unknown, reason: string, old_sha256: string, writeRow: WriteRow, before: (record: Record<string, any>) => void}} opts */
|
|
251
|
+
export async function amendAcceptanceBlock({ runId, id, acceptance, reason, old_sha256, writeRow, before }) {
|
|
252
|
+
return mutateRun(runId, null, writeRow, (record) => {
|
|
253
|
+
before(record);
|
|
254
|
+
const prepared = prepareAcceptanceAmendment(record, id, acceptance);
|
|
255
|
+
if (prepared.old_sha256 !== old_sha256) throw new StateError('acceptance-moved', `acceptance changed while confirming block ${id}; confirm again`);
|
|
256
|
+
const block = record.blocks[id];
|
|
257
|
+
block.acceptance = prepared.acceptance;
|
|
258
|
+
const checks = prepared.acceptance.filter((c) => c.kind === 'human_check').map((c) => c.clause);
|
|
259
|
+
if (checks.length) block.human_checks = checks;
|
|
260
|
+
else delete block.human_checks;
|
|
261
|
+
// Unique even when a later amendment restores an earlier list of clauses.
|
|
262
|
+
block.acceptance_revision = randomUUID();
|
|
263
|
+
const row = { event: 'block.acceptance_amended', block: id, old_sha256, new_sha256: prepared.new_sha256, clauses: prepared.acceptance.length, reason, by: 'human' };
|
|
264
|
+
return { result: row, rows: [row] };
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
232
268
|
/**
|
|
233
269
|
* Under the run lock: load the run, check it is active and (when `id` is given) that the block
|
|
234
270
|
* is open, run `mutate`, write its signed `rows` through the ledger writer, THEN save the record —
|
|
@@ -315,6 +351,7 @@ function refuseConfigOwner(owned, kinds, guarded) {
|
|
|
315
351
|
* and the dispatch row carry the floor level, and the row adds `requested_level` and
|
|
316
352
|
* `trigger: 'docs_floor'`. B52: the entry also records `tree_at_open` ({@link treeAtOpen}), the
|
|
317
353
|
* tree's changes at open, which `block close` reads to tell the coder's changes from earlier ones.
|
|
354
|
+
* B134: `base_branch` is the workspace's current branch, or null when detached.
|
|
318
355
|
* B58: with `review.external.required` set, an owned entry that owns the project's config is
|
|
319
356
|
* refused (`config-owned`, {@link refuseConfigOwner}).
|
|
320
357
|
* B59: `decision` (the rules lane of a brief-mode block) is written as a signed `decision` row
|
|
@@ -352,12 +389,13 @@ export async function openBlock(opts) {
|
|
|
352
389
|
if (typeof level !== 'string' || !LEVEL.test(level)) throw new StateError('bad-level', 'level must be L0..L3');
|
|
353
390
|
if (!Number.isInteger(attempt) || attempt < 1) throw new StateError('bad-attempt', 'attempt must be an integer >= 1');
|
|
354
391
|
if (lines !== null && !(Number.isInteger(lines) && lines > 0)) throw new StateError('bad-lines', 'lines forecast must be a positive integer');
|
|
355
|
-
if (opts.kind !== undefined && !BLOCK_KINDS.includes(opts.kind)) throw new StateError('bad-kind', `kind must be one of ${BLOCK_KINDS.join(', ')}`);
|
|
392
|
+
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(', ')}`);
|
|
356
393
|
if (opts.decision && opts.decision.answer !== level) throw new StateError('bad-decision', `the decision answer ${opts.decision.answer} is not the requested level ${level}`);
|
|
357
394
|
const owned = assertOwned(opts.owned);
|
|
358
395
|
const acceptance = assertAcceptance(opts.acceptance);
|
|
396
|
+
const humanChecks = acceptance.filter((c) => c.kind === 'human_check').map((c) => c.clause);
|
|
359
397
|
const tests = opts.tests === undefined ? null : [...assertOwned(opts.tests, { label: 'test files', code: 'bad-tests' })];
|
|
360
|
-
const kind = blockKind({ owned, declared: opts.kind });
|
|
398
|
+
const kind = opts.kind === 'light' ? 'light' : blockKind({ owned, declared: opts.kind });
|
|
361
399
|
const dispatched = dispatchLevel({ lane: level, kind, cfg: opts.cfg });
|
|
362
400
|
const pointer = opts.brief ? briefPointer(opts.brief.path, opts.brief.content) : null;
|
|
363
401
|
|
|
@@ -384,6 +422,7 @@ export async function openBlock(opts) {
|
|
|
384
422
|
try {
|
|
385
423
|
const { block, reused } = await mutateRun(runId, null, writeRow, async (record) => {
|
|
386
424
|
if (record.blocks[id]?.status === 'open') throw new StateError('already-open', `block ${id} is already open`);
|
|
425
|
+
if (record.blocks[id]?.status === 'awaiting_human_check') throw new StateError('already-open', `block ${id} is awaiting a human check`);
|
|
387
426
|
// B57: what each exact owned entry is on disk, under the lock (as `block claim`) — a regular
|
|
388
427
|
// file owns only itself, here and in every later check
|
|
389
428
|
const kinds = ownedKinds(record.workspace, owned);
|
|
@@ -391,6 +430,8 @@ export async function openBlock(opts) {
|
|
|
391
430
|
const prior = previousTree(record);
|
|
392
431
|
const baseSha = await resolveCommit(prior ? prior.base_sha : opts.base ?? 'HEAD', record.workspace);
|
|
393
432
|
if (baseSha !== stampedBase || record.workspace !== workspace) throw new StateError('base-moved', `the base moved while block ${id} was being opened; run block open again`);
|
|
433
|
+
const branch = await git(['git', 'symbolic-ref', '--quiet', '--short', 'HEAD'], record.workspace, [0, 1]);
|
|
434
|
+
const baseBranch = branch.code === 0 ? branch.stdout.trim() : null;
|
|
394
435
|
// B62a: the tree is planned (read-only checks) before the overlap check, which needs its path
|
|
395
436
|
const planned = opts.worktree ? await opts.worktree({ baseSha, previous: prior }) : null;
|
|
396
437
|
const self = { owned_files: owned, owned_kinds: kinds, worktree: planned ? { path: planned.path } : null };
|
|
@@ -402,7 +443,8 @@ export async function openBlock(opts) {
|
|
|
402
443
|
// passed; a tree made here is rolled back below if the rows or the save then fail
|
|
403
444
|
made = planned ? await planned.make() : null;
|
|
404
445
|
const tree = made;
|
|
405
|
-
const entry = { block: id, base_sha: baseSha, 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 } : {}), ...(tree ? { worktree: tree.worktree } : {}) };
|
|
446
|
+
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 } : {}), ...(tree ? { worktree: tree.worktree } : {}) };
|
|
447
|
+
if (humanChecks.length > 0) Object.assign(entry, { human_checks: humanChecks });
|
|
406
448
|
record.blocks[id] = entry;
|
|
407
449
|
const floor = dispatched.trigger === 'docs_floor' ? { requested_level: level, trigger: 'docs_floor' } : {};
|
|
408
450
|
const created = tree?.created ? [{ event: 'worktree.created', block: id, path: tree.worktree.path, branch: tree.worktree.branch, base: tree.worktree.base_sha }] : [];
|
|
@@ -411,7 +453,7 @@ export async function openBlock(opts) {
|
|
|
411
453
|
rows: [
|
|
412
454
|
...(opts.decision ? [{ ...opts.decision, event: 'decision', decision_id: opts.decisionId ?? randomUUID(), block: id }] : []),
|
|
413
455
|
...created,
|
|
414
|
-
{ event: 'dispatch', block: id, level: dispatched.level, kind, ...floor, attempt, base_sha: baseSha, owned_files: entry.owned_files, lines_forecast: lines, ...(tests ? { test_files: tests } : {}) },
|
|
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 } : {}) },
|
|
415
457
|
],
|
|
416
458
|
};
|
|
417
459
|
});
|
|
@@ -444,11 +486,14 @@ export async function attemptBlock({ runId, id, writeRow }) {
|
|
|
444
486
|
* in between touched a file this block owns (that is a conflict ⇒ stop). Rename detection is OFF
|
|
445
487
|
* (`--no-renames`): a rename lists both its old and its new path, so moving an owned file away
|
|
446
488
|
* is a conflict too. Never silent. Owned means `ownsFile` (B57: a directory entry owns the files
|
|
447
|
-
* below it). B62a: refused for
|
|
489
|
+
* below it). B62a: refused for an open block with its own worktree (`worktree-rebase`).
|
|
490
|
+
* B134: a closed worktree block instead rebases its branch onto its recorded base branch,
|
|
491
|
+
* retaining every changed owned file's blob; a conflict or changed blob restores the old head.
|
|
448
492
|
* @param {{runId: string, id: string, head?: string, writeRow: WriteRow}} opts
|
|
449
493
|
* @returns {Promise<{old_base: string, new_base: string}>}
|
|
450
494
|
*/
|
|
451
495
|
export async function rebaseBlock({ runId, id, head = 'HEAD', writeRow }) {
|
|
496
|
+
if (['closed', 'awaiting_human_check'].includes((await readRun(runId)).blocks[id]?.status)) return rebaseClosedBlock({ runId, id, writeRow });
|
|
452
497
|
return mutateRun(runId, id, writeRow, async (record, block) => {
|
|
453
498
|
// B62a: a worktree block's base is the commit its tree started from; moving it would leave the
|
|
454
499
|
// tree on its old base, so the rebase is refused
|
|
@@ -466,6 +511,75 @@ export async function rebaseBlock({ runId, id, head = 'HEAD', writeRow }) {
|
|
|
466
511
|
});
|
|
467
512
|
}
|
|
468
513
|
|
|
514
|
+
/**
|
|
515
|
+
* Hash committed paths, with null for a deletion. NUL-delimited tree entries preserve unusual
|
|
516
|
+
* file names; filter exact names because git's path selection may also return descendants.
|
|
517
|
+
* @param {string} cwd @param {string} ref @param {string[]} files
|
|
518
|
+
* @returns {Promise<Map<string, string | null>>}
|
|
519
|
+
*/
|
|
520
|
+
async function committedBlobs(cwd, ref, files) {
|
|
521
|
+
const hashes = new Map(files.map((file) => [file, /** @type {string | null} */ (null)]));
|
|
522
|
+
if (files.length === 0) return hashes;
|
|
523
|
+
const tree = await git(['git', 'ls-tree', '-r', '-z', '--full-tree', ref, '--', ...files], cwd);
|
|
524
|
+
for (const entry of tree.stdout.split('\0').filter(Boolean)) {
|
|
525
|
+
const tab = entry.indexOf('\t');
|
|
526
|
+
const file = entry.slice(tab + 1);
|
|
527
|
+
if (hashes.has(file)) hashes.set(file, entry.slice(0, tab).split(' ')[2]);
|
|
528
|
+
}
|
|
529
|
+
return hashes;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Closed blocks have a separate mutation path: check their status again under the lock,
|
|
534
|
+
* restore the branch on failure (including a failed signed write), and retain closed/awaiting status.
|
|
535
|
+
* @param {{runId: string, id: string, writeRow: WriteRow}} opts
|
|
536
|
+
* @returns {Promise<{old_base: string, new_base: string}>}
|
|
537
|
+
*/
|
|
538
|
+
async function rebaseClosedBlock({ runId, id, writeRow }) {
|
|
539
|
+
return withRunLock(runId, async () => {
|
|
540
|
+
const record = await readRun(runId);
|
|
541
|
+
if (record.status !== 'active') throw new StateError('run-ended', `run ${runId} has ended`);
|
|
542
|
+
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}`);
|
|
544
|
+
if (!block.base_branch) throw new StateError('rebase-no-base', `block ${id} has no recorded base branch`);
|
|
545
|
+
const tree = block.worktree.path;
|
|
546
|
+
const branch = await git(['git', 'symbolic-ref', '--quiet', 'HEAD'], tree, [0, 1]);
|
|
547
|
+
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
|
+
// A reset must never discard edits made after close, or abort an earlier rebase.
|
|
549
|
+
const status = await git(['git', 'status', '--porcelain'], tree);
|
|
550
|
+
if (status.stdout.length > 0) throw new StateError('rebase-conflict', 'rebase-conflict');
|
|
551
|
+
const oldHead = await resolveCommit('HEAD', tree);
|
|
552
|
+
const newBase = await resolveCommit(`refs/heads/${block.base_branch}`, record.workspace);
|
|
553
|
+
const top = await repoTop(record.workspace);
|
|
554
|
+
const prefix = path.relative(top, realpathSync(record.workspace)).split(path.sep).join('/');
|
|
555
|
+
const diff = await git(['git', 'diff', '--no-renames', '--name-only', '-z', block.base_sha, oldHead, '--'], tree);
|
|
556
|
+
const files = diff.stdout.split('\0').filter((file) => file.length > 0 && (
|
|
557
|
+
ownsFile(block.owned_files, file, block.owned_kinds) ||
|
|
558
|
+
(prefix !== '' && file.startsWith(`${prefix}/`) && ownsFile(block.owned_files, file.slice(prefix.length + 1), block.owned_kinds))
|
|
559
|
+
)).sort();
|
|
560
|
+
const before = await committedBlobs(tree, oldHead, files);
|
|
561
|
+
const moved = { old_base: block.base_sha, new_base: newBase };
|
|
562
|
+
try {
|
|
563
|
+
const rebased = await exec(['git', '-C', tree, 'rebase', newBase], { cwd: record.workspace, env: gitEnv(), timeoutMs: 30000 });
|
|
564
|
+
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
|
+
if (changed.length > 0) throw new StateError('rebase-changed-reviewed', `rebase-changed-reviewed ${changed.join(', ')}`);
|
|
568
|
+
block.base_sha = newBase;
|
|
569
|
+
block.worktree.base_sha = newBase;
|
|
570
|
+
await writeSigned(runId, writeRow, { event: 'block.rebase', block: id, ...moved, closed: true });
|
|
571
|
+
await saveRun(record);
|
|
572
|
+
return moved;
|
|
573
|
+
} catch (err) {
|
|
574
|
+
// --abort restores a conflicted rebase; after a completed rebase there is nothing to
|
|
575
|
+
// abort, and reset restores the old branch head. The tree was clean before either.
|
|
576
|
+
await exec(['git', '-C', tree, 'rebase', '--abort'], { cwd: record.workspace, env: gitEnv(), timeoutMs: 30000 });
|
|
577
|
+
await git(['git', 'reset', '--hard', oldHead], tree);
|
|
578
|
+
throw err;
|
|
579
|
+
}
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
|
|
469
583
|
/**
|
|
470
584
|
* `block claim` — assign one EXACT path (an orphan, or any unowned path) to `id`; refused for a
|
|
471
585
|
* glob, and when another open block owns it — or anything it would own: the claimed path becomes an
|
|
@@ -513,13 +627,35 @@ export async function checkScope({ runId, id, files, writeRow }) {
|
|
|
513
627
|
|
|
514
628
|
/** @param {{runId: string, id: string, reason: string, writeRow: WriteRow, event?: string}} opts */
|
|
515
629
|
export async function stopBlock({ runId, id, reason, writeRow, event = 'block.stop' }) {
|
|
516
|
-
await mutateRun(runId,
|
|
630
|
+
await mutateRun(runId, null, writeRow, (record) => {
|
|
631
|
+
const block = record.blocks[id];
|
|
632
|
+
if (!['open', 'awaiting_human_check'].includes(block?.status)) throw new StateError('no-block', `block ${id} is not open in run ${runId}`);
|
|
517
633
|
block.status = 'stopped';
|
|
518
634
|
block.stop_reason = reason;
|
|
519
635
|
return { result: undefined, rows: [{ event, block: id, reason }] };
|
|
520
636
|
});
|
|
521
637
|
}
|
|
522
638
|
|
|
639
|
+
/**
|
|
640
|
+
* Called only after the CLI's terminal confirmation; recheck under the lock before signing.
|
|
641
|
+
* @param {{runId: string, id: string, note: string, proof?: string | null, writeRow: WriteRow, now?: Date}} opts
|
|
642
|
+
*/
|
|
643
|
+
export async function humanPassBlock({ runId, id, note, proof = null, writeRow, now = new Date() }) {
|
|
644
|
+
return mutateRun(runId, null, writeRow, (record) => {
|
|
645
|
+
const block = record.blocks[id];
|
|
646
|
+
if (block?.status !== 'awaiting_human_check') throw new StateError('not-awaiting', `block ${id} is not awaiting a human check`);
|
|
647
|
+
if (typeof note !== 'string' || note.trim().length === 0) throw new StateError('usage', 'block human-pass needs --note <text>');
|
|
648
|
+
if (proof !== null && (typeof proof !== 'string' || !existsSync(proof))) throw new StateError('usage', '--proof must name an existing directory or file');
|
|
649
|
+
const grant = record.autopilot;
|
|
650
|
+
if (grant && !['stopped', 'expired'].includes(grant.status) && !(now.getTime() >= Date.parse(grant.until))) {
|
|
651
|
+
throw new StateError('autopilot-active', `block human-pass is refused while an autopilot grant is active on run ${runId}`);
|
|
652
|
+
}
|
|
653
|
+
const row = { event: 'block.human_pass', block: id, note, proof, checks: [...block.human_checks], by: 'human' };
|
|
654
|
+
block.status = 'closed';
|
|
655
|
+
return { result: row, rows: [row] };
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
|
|
523
659
|
/**
|
|
524
660
|
* B8's rows of the block gate, in order: worker pin ⇒ `worker.replaced`/`worker.down` (block
|
|
525
661
|
* stopped); the block's signed `dispatch` row must exist and verify, and every other
|
|
@@ -533,16 +669,44 @@ export async function stopBlock({ runId, id, reason, writeRow, event = 'block.st
|
|
|
533
669
|
* livePid?: number, probe?: import('./run.mjs').StartTimeProbe,
|
|
534
670
|
* extraChecks?: Array<() => Promise<{ok: boolean, reason?: string}>>,
|
|
535
671
|
* closeRows?: Record<string, any>[],
|
|
672
|
+
* dryRun?: boolean,
|
|
536
673
|
* }} opts
|
|
537
|
-
* @returns {Promise<{ok: boolean, status: 'closed' | 'stopped' | 'open', reason?: string, event?: string}>}
|
|
674
|
+
* @returns {Promise<{ok: boolean, status: 'closed' | 'awaiting_human_check' | 'stopped' | 'open', reason?: string, event?: string}>}
|
|
538
675
|
*/
|
|
539
|
-
export async function closeBlock({ runId, id, rows, writeRow, livePid, probe, extraChecks = [], closeRows = [] }) {
|
|
676
|
+
export async function closeBlock({ runId, id, rows, writeRow, livePid, probe, extraChecks = [], closeRows = [], dryRun = false }) {
|
|
540
677
|
const record = await readRun(runId);
|
|
541
678
|
if (record.blocks[id]?.status !== 'open') throw new StateError('no-block', `block ${id} is not open in run ${runId}`);
|
|
542
679
|
|
|
543
|
-
|
|
680
|
+
let pin = await checkWorkerPin(record, { livePid, probe });
|
|
681
|
+
const pins = record.workers ?? (record.worker ? [record.worker] : []);
|
|
682
|
+
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
|
+
// Ledgers written before B107 omit worker identity and use the run's pin check above.
|
|
684
|
+
if (counted.some((row) => row.worker !== undefined && !pins.some((p) => p.pid === row.worker?.pid && p.started_at === row.worker?.started_at))) {
|
|
685
|
+
pin = { ok: false, event: 'worker.replaced', reason: 'a counted review result does not name a pinned worker' };
|
|
686
|
+
}
|
|
687
|
+
if (pin.ok) {
|
|
688
|
+
const repoRoot = await repoRootOf(record.workspace);
|
|
689
|
+
let names;
|
|
690
|
+
try { names = readdirSync(reviewQueue.queueDir(repoRoot)); }
|
|
691
|
+
catch (err) { if (err.code !== 'ENOENT') throw err; names = []; }
|
|
692
|
+
for (const name of names.filter((n) => /^[0-9a-f]{24}\.json$/.test(n))) {
|
|
693
|
+
const ticket = name.slice(0, 24);
|
|
694
|
+
const queued = reviewQueue.readTicket(repoRoot, ticket);
|
|
695
|
+
if (queued.run !== runId || queued.block !== id) continue;
|
|
696
|
+
const claimKey = `ticket\0${runId}\0${ticket}`;
|
|
697
|
+
const file = path.join(os.homedir(), '.code-forge', 'locks', `${createHash('sha256').update(claimKey).digest('hex')}.lock`);
|
|
698
|
+
let claim;
|
|
699
|
+
try { claim = JSON.parse(readFileSync(file, 'utf8')); }
|
|
700
|
+
catch (err) { if (err.code === 'ENOENT') continue; throw err; }
|
|
701
|
+
const owner = pins.find((p) => p.pid === claim.pid && p.started_at === claim.start);
|
|
702
|
+
if (owner && !(await checkWorkerPin({ worker: owner }, { probe })).ok) {
|
|
703
|
+
pin = { ok: false, event: 'worker.down', reason: `dead pinned worker pid ${owner.pid} holds ticket ${ticket}` };
|
|
704
|
+
break;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
544
708
|
if (!pin.ok) {
|
|
545
|
-
await stopBlock({ runId, id, reason: pin.reason, writeRow, event: pin.event });
|
|
709
|
+
if (!dryRun) await stopBlock({ runId, id, reason: pin.reason, writeRow, event: pin.event });
|
|
546
710
|
return { ok: false, status: 'stopped', event: pin.event, reason: pin.reason };
|
|
547
711
|
}
|
|
548
712
|
|
|
@@ -555,25 +719,35 @@ export async function closeBlock({ runId, id, rows, writeRow, livePid, probe, ex
|
|
|
555
719
|
const reasons = bad.map((x) => `${x.row.event}: ${x.result.reason}`);
|
|
556
720
|
if (!dispatchOk) reasons.push('no verified dispatch row');
|
|
557
721
|
const reason = reasons.join('; ');
|
|
558
|
-
await stopBlock({ runId, id, reason, writeRow, event: 'ledger.tamper' });
|
|
722
|
+
if (!dryRun) await stopBlock({ runId, id, reason, writeRow, event: 'ledger.tamper' });
|
|
559
723
|
return { ok: false, status: 'stopped', event: 'ledger.tamper', reason };
|
|
560
724
|
}
|
|
561
725
|
|
|
726
|
+
const failures = [];
|
|
562
727
|
for (const check of extraChecks) {
|
|
563
728
|
const result = await check();
|
|
564
|
-
if (!result.ok)
|
|
729
|
+
if (!result.ok) {
|
|
730
|
+
if (!dryRun) return { ok: false, status: 'open', reason: result.reason ?? 'check failed' };
|
|
731
|
+
failures.push(result.reason ?? 'check failed');
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
if (dryRun) {
|
|
735
|
+
if (record.orphans.length > 0) failures.push(`orphans: ${record.orphans.join(', ')}`);
|
|
736
|
+
return { ok: failures.length === 0, status: 'open', ...(failures.length ? { reason: failures.join('; ') } : {}) };
|
|
565
737
|
}
|
|
566
738
|
|
|
567
|
-
/** @typedef {{ok: boolean, status: 'closed' | 'stopped' | 'open', reason?: string, event?: string}} CloseResult */
|
|
739
|
+
/** @typedef {{ok: boolean, status: 'closed' | 'awaiting_human_check' | 'stopped' | 'open', reason?: string, event?: string}} CloseResult */
|
|
568
740
|
return mutateRun(runId, id, writeRow, (current, block) => {
|
|
569
741
|
if (current.orphans.length > 0) {
|
|
570
742
|
/** @type {CloseResult} */
|
|
571
743
|
const refused = { ok: false, status: 'open', reason: `orphans: ${current.orphans.join(', ')}` };
|
|
572
744
|
return { result: refused, rows: [] };
|
|
573
745
|
}
|
|
574
|
-
block.
|
|
746
|
+
const checks = block.human_checks ?? [];
|
|
747
|
+
block.status = checks.length > 0 ? 'awaiting_human_check' : 'closed';
|
|
575
748
|
/** @type {CloseResult} */
|
|
576
|
-
const closed = { ok: true, status:
|
|
577
|
-
return { result: closed, rows: [...closeRows, { event: 'block.close', block: id, status: 'complete' }
|
|
749
|
+
const closed = { ok: true, status: block.status };
|
|
750
|
+
return { result: closed, rows: [...closeRows, { event: 'block.close', block: id, status: 'complete' },
|
|
751
|
+
...(checks.length > 0 ? [{ event: 'block.awaiting_human_check', block: id, checks }] : [])] };
|
|
578
752
|
});
|
|
579
753
|
}
|
package/src/state/run.mjs
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* parameter; the CLI verb does the static import) and are SIGNED with the run key first.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { existsSync } from 'node:fs';
|
|
15
16
|
import { mkdir, readFile, rename, rmdir, writeFile } from 'node:fs/promises';
|
|
16
17
|
import path from 'node:path';
|
|
17
18
|
import { exec } from '../util/exec.mjs';
|
|
@@ -31,7 +32,7 @@ const LOCK_POLL_MS = 25;
|
|
|
31
32
|
*/
|
|
32
33
|
export async function processStartTime(pid) {
|
|
33
34
|
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
34
|
-
const res = await exec(['ps', '-o', 'lstart=', '-p', String(pid)], {
|
|
35
|
+
const res = await exec([existsSync('/bin/ps') ? '/bin/ps' : 'ps', '-o', 'lstart=', '-p', String(pid)], {
|
|
35
36
|
env: { ...process.env, LC_ALL: 'C' },
|
|
36
37
|
okExitCodes: [0, 1],
|
|
37
38
|
timeoutMs: 10000,
|
|
@@ -177,6 +178,7 @@ export async function startRun(opts) {
|
|
|
177
178
|
orchestrator_pid: opts.orchestratorPid ?? process.pid,
|
|
178
179
|
solo_pid: opts.soloPid ?? null,
|
|
179
180
|
worker,
|
|
181
|
+
workers: worker ? [worker] : [],
|
|
180
182
|
status: 'active',
|
|
181
183
|
blocks: {},
|
|
182
184
|
orphans: [],
|
|
@@ -250,6 +252,10 @@ export async function reloadRun({ runId, config, readPending = () => [], effecti
|
|
|
250
252
|
if (oldHash === newHash) return { changed: [], oldHash, newHash, pinned: 0, rowError: null };
|
|
251
253
|
const before = state && Object.hasOwn(stored, state.hash) ? stored[state.hash] : undefined;
|
|
252
254
|
if (state && (before === null || typeof before !== 'object')) throw new StateError('config-snapshot', `run ${runId}: the current config snapshot is missing from the run record`);
|
|
255
|
+
// B138: close authority belongs to the run-start snapshot, including legacy runs.
|
|
256
|
+
if (before?.close?.require_human !== config.close?.require_human) {
|
|
257
|
+
throw new StateError('immutable-key', 'close.require_human cannot change mid-run (fixed by the owner before the run starts)');
|
|
258
|
+
}
|
|
253
259
|
/** @type {string[]} */
|
|
254
260
|
let frozen;
|
|
255
261
|
if (state) {
|
|
@@ -309,15 +315,18 @@ export async function reloadRun({ runId, config, readPending = () => [], effecti
|
|
|
309
315
|
|
|
310
316
|
/**
|
|
311
317
|
* `run start --reattach`: re-pin a restarted worker (the orchestrator only — §4.8).
|
|
312
|
-
* @param {{runId: string, workerPid: number, writeRow: WriteRow, probe?: StartTimeProbe}} opts
|
|
318
|
+
* @param {{runId: string, workerPid: number, writeRow: WriteRow, probe?: StartTimeProbe, add?: boolean}} opts
|
|
313
319
|
*/
|
|
314
|
-
export async function reattachWorker({ runId, workerPid, writeRow, probe = processStartTime }) {
|
|
320
|
+
export async function reattachWorker({ runId, workerPid, writeRow, probe = processStartTime, add = false }) {
|
|
315
321
|
if (!isSignalablePid(workerPid)) throw new StateError('bad-worker-pid', 'worker pid must be an integer > 1');
|
|
316
322
|
return withRunLock(runId, async () => {
|
|
317
323
|
const current = await readRun(runId);
|
|
318
324
|
if (current.status !== 'active') throw new StateError('run-ended', `run ${runId} has ended`);
|
|
319
|
-
|
|
320
|
-
|
|
325
|
+
const pin = await pinWorker(workerPid, probe);
|
|
326
|
+
const pins = current.workers ?? (current.worker ? [current.worker] : []);
|
|
327
|
+
current.workers = add ? [...pins, pin] : [pin];
|
|
328
|
+
current.worker = current.workers[0];
|
|
329
|
+
await writeSigned(runId, writeRow, { event: add ? 'worker.add' : 'worker.reattach', pid: workerPid });
|
|
321
330
|
await saveRun(current);
|
|
322
331
|
return current;
|
|
323
332
|
});
|
|
@@ -331,7 +340,18 @@ export async function reattachWorker({ runId, workerPid, writeRow, probe = proce
|
|
|
331
340
|
* @returns {Promise<{ok: boolean, event?: 'worker.replaced' | 'worker.down', reason?: string}>}
|
|
332
341
|
*/
|
|
333
342
|
export async function checkWorkerPin(record, { livePid, probe = processStartTime } = {}) {
|
|
334
|
-
|
|
343
|
+
let pin = record.worker;
|
|
344
|
+
if (record.workers?.length > 1) {
|
|
345
|
+
const pins = record.workers;
|
|
346
|
+
if (pins.some((p) => !isSignalablePid(p.pid)) || (livePid !== undefined && !pins.some((p) => p.pid === livePid))) {
|
|
347
|
+
return { ok: false, event: 'worker.replaced', reason: 'the live worker does not match a pinned worker' };
|
|
348
|
+
}
|
|
349
|
+
if (livePid === undefined) {
|
|
350
|
+
const live = await Promise.all(pins.map(async (p) => (await probe(p.pid)) === p.started_at));
|
|
351
|
+
return live.some(Boolean) ? { ok: true } : { ok: false, event: 'worker.down', reason: 'no pinned worker is running' };
|
|
352
|
+
}
|
|
353
|
+
pin = pins.find((p) => p.pid === livePid);
|
|
354
|
+
}
|
|
335
355
|
if (!pin) return { ok: false, event: 'worker.down', reason: 'no worker is pinned for this run' };
|
|
336
356
|
if (!isSignalablePid(pin.pid)) return { ok: false, event: 'worker.replaced', reason: 'the pinned worker pid is not an integer > 1' };
|
|
337
357
|
if (livePid !== undefined && livePid !== pin.pid) {
|
|
@@ -380,14 +400,21 @@ export async function endRun({ runId, writeRow, stopWorker = stopPinnedWorker, p
|
|
|
380
400
|
return withRunLock(runId, async () => {
|
|
381
401
|
const current = await readRun(runId);
|
|
382
402
|
if (current.status !== 'active') throw new StateError('run-ended', `run ${runId} has already ended`);
|
|
403
|
+
const awaiting = Object.keys(current.blocks).filter((id) => current.blocks[id].status === 'awaiting_human_check');
|
|
404
|
+
if (awaiting.length > 0) throw new StateError('awaiting-human-check', `run ${runId} has blocks awaiting human check: ${awaiting.join(', ')}; human-pass or stop them first`);
|
|
383
405
|
const open = Object.keys(current.blocks).filter((id) => current.blocks[id].status === 'open');
|
|
384
406
|
if (open.length > 0) throw new StateError('open-blocks', `run ${runId} still has open blocks: ${open.join(', ')}`);
|
|
385
407
|
const pin = await checkWorkerPin(current, { probe });
|
|
386
408
|
if (!pin.ok) await writeSigned(runId, writeRow, { event: pin.event, reason: pin.reason });
|
|
387
|
-
|
|
409
|
+
let stopped = false;
|
|
410
|
+
for (const worker of current.workers ?? (current.worker ? [current.worker] : [])) {
|
|
411
|
+
if ((await checkWorkerPin({ worker }, { probe })).ok) {
|
|
412
|
+
stopped = (await stopWorker(worker, { probe })).stopped || stopped;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
388
415
|
current.status = 'ended';
|
|
389
416
|
current.ended_at = new Date().toISOString();
|
|
390
|
-
await writeSigned(runId, writeRow, { event: 'run.end', worker_check: pin.ok ? 'ok' : pin.event, worker_stopped:
|
|
417
|
+
await writeSigned(runId, writeRow, { event: 'run.end', worker_check: pin.ok ? 'ok' : pin.event, worker_stopped: stopped });
|
|
391
418
|
await saveRun(current);
|
|
392
419
|
return current;
|
|
393
420
|
});
|