@codedology/code-forge 0.15.4 → 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 +26 -0
- package/README.md +1 -1
- 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 +4 -2
- package/schema/facts.schema.json +1 -1
- package/skill/SKILL.md +1 -1
- package/skill/references/adapters/solo.md +1 -1
- package/skill/references/decisions.md +1 -1
- package/skill/references/plan.md +14 -5
- package/skill/references/review.md +1 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/facts-sheet.md +9 -3
- package/src/cli/review-file.mjs +5 -0
- package/src/cli/spawn.mjs +26 -22
- package/src/cli/status.mjs +37 -5
- package/src/decide/escalation.mjs +5 -5
- 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/definitions.mjs +170 -0
- package/src/review/fixloop.mjs +4 -4
- package/src/review/gate-check.mjs +20 -4
- package/src/review/packet.mjs +58 -20
- package/src/session/facts.mjs +234 -64
- package/src/session/spawn.mjs +70 -19
- package/src/state/block-end.mjs +3 -1
- package/src/state/block.mjs +17 -5
- package/src/state/run.mjs +12 -5
- package/src/util/native.mjs +2 -0
- package/src/worker/engine.mjs +11 -26
- package/src/worker/loop.mjs +6 -4
- package/src/worker/queue.mjs +2 -1
- package/src/worker/review-state.mjs +15 -3
- package/src/worker/watch.mjs +92 -89
- package/src/worker/worker-warning.mjs +35 -0
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
|
@@ -613,8 +613,20 @@ async function rebaseWorktreeBlock({ runId, id, writeRow, acceptChanged, reason,
|
|
|
613
613
|
const before = closed ? await committedBlobs(tree, reviewedHead, files) : null;
|
|
614
614
|
const moved = { old_base: block.base_sha, new_base: newBase };
|
|
615
615
|
try {
|
|
616
|
-
|
|
617
|
-
|
|
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
|
+
}
|
|
618
630
|
const after = before === null ? null : await committedBlobs(tree, 'HEAD', files);
|
|
619
631
|
const changed = after === null ? [] : files.filter((file) => before.get(file) !== after.get(file));
|
|
620
632
|
const repoRoot = await repoRootOf(record.workspace);
|
|
@@ -652,9 +664,9 @@ async function rebaseWorktreeBlock({ runId, id, writeRow, acceptChanged, reason,
|
|
|
652
664
|
await saveRun(record);
|
|
653
665
|
return moved;
|
|
654
666
|
} catch (err) {
|
|
655
|
-
//
|
|
656
|
-
//
|
|
657
|
-
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 });
|
|
658
670
|
await git(['git', 'reset', '--hard', oldHead], tree);
|
|
659
671
|
throw err;
|
|
660
672
|
}
|
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) {
|
package/src/util/native.mjs
CHANGED
|
@@ -83,6 +83,8 @@ export async function daemonRequest(run, request, { config, timeoutMs = request.
|
|
|
83
83
|
socket.write(`${JSON.stringify(request)}\n`);
|
|
84
84
|
} else {
|
|
85
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);
|
|
86
88
|
if (request.method === 'wait-block' && (result.block !== request.block || !Array.isArray(result.files) || typeof result.done !== 'boolean'
|
|
87
89
|
|| !result.files.every((/** @type {any} */ file) => file && typeof file.file === 'string' && typeof file.status === 'string'))) return finish(null);
|
|
88
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);
|
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 });
|
package/src/worker/loop.mjs
CHANGED
|
@@ -44,7 +44,8 @@ import { expiryChecks } from '../autopilot/limits.mjs';
|
|
|
44
44
|
import { loadProjectConfig } from '../config/load.mjs';
|
|
45
45
|
import { reviewWorkers } from '../config/validate.mjs';
|
|
46
46
|
import { cacheHoursFrom, createDefaultKeyStore, resolveKey } from '../keys/store.mjs';
|
|
47
|
-
import {
|
|
47
|
+
import { createTailReader } from '../ledger/tail.mjs';
|
|
48
|
+
import { appendRow } from '../ledger/write.mjs';
|
|
48
49
|
import { providerLimit, spawnSession } from '../session/spawn.mjs';
|
|
49
50
|
import { snapshotFor } from '../state/config-snapshot.mjs';
|
|
50
51
|
import { runRecordPath } from '../state/paths.mjs';
|
|
@@ -243,6 +244,7 @@ export function recordedVerdict(result) {
|
|
|
243
244
|
* @property {{beforeLock?: () => void, afterCount?: (count: number) => Promise<unknown>}} [hooks] - deterministic boot races.
|
|
244
245
|
* @property {typeof readTicket} [readTicket] - the ticket read (a test seam).
|
|
245
246
|
* @property {typeof import('../util/exec.mjs').exec} [watchExec] - watch git exec seam.
|
|
247
|
+
* @property {() => number} [watchNow] - watch debounce clock (default: Date.now).
|
|
246
248
|
* @property {Parameters<typeof watchTick>[0]['hooks']} [watchHooks] - deterministic watch races.
|
|
247
249
|
* @property {import('../keys/store.mjs').KeyStore} [store] - default: B2's production store.
|
|
248
250
|
* @property {any} [opRead]
|
|
@@ -250,7 +252,7 @@ export function recordedVerdict(result) {
|
|
|
250
252
|
* @property {Partial<Record<'claude'|'codex'|'grok', string>>} [bins]
|
|
251
253
|
* @property {(row: Record<string, any>) => Promise<unknown>} [writeRow] - default: B6 `appendRow`.
|
|
252
254
|
* @property {() => Promise<Array<Record<string, any>>>} [readRows] - the ledger rows the fix-loop
|
|
253
|
-
* state is anchored in; default:
|
|
255
|
+
* state is anchored in; default: one project tail reader for the worker.
|
|
254
256
|
* @property {{write: (s: string) => unknown}} [stderr]
|
|
255
257
|
* @property {import('../review/triage.mjs').JevAsk} [jev] - S1 for triage and the recheck (tests
|
|
256
258
|
* inject a mock); default: `askJev` with the resolved Jev key, none without one.
|
|
@@ -272,7 +274,7 @@ export async function createWorker(opts, deps = {}) {
|
|
|
272
274
|
const loadRun = deps.readRun ?? readRun;
|
|
273
275
|
const loadTicket = deps.readTicket ?? readTicket;
|
|
274
276
|
const writeRow = deps.writeRow ?? ((/** @type {Record<string, any>} */ row) => appendRow(row, { slug }));
|
|
275
|
-
const readRows = deps.readRows ?? (
|
|
277
|
+
const readRows = deps.readRows ?? createTailReader(slug).read;
|
|
276
278
|
// B48: the run's own writer (unsigned base; the checks sign their rows with the run key)
|
|
277
279
|
const expiryCheck = deps.expiryCheck ?? (() => expiryChecks(runId, new Date(), { writeRow }));
|
|
278
280
|
const warnStream = deps.stderr ?? process.stderr;
|
|
@@ -356,7 +358,7 @@ export async function createWorker(opts, deps = {}) {
|
|
|
356
358
|
const ledger = (row) => writeRow(signRow({ run: runId, ...row }, key));
|
|
357
359
|
// Shared across direct ticks and the scheduler, so running tickets are never superseded.
|
|
358
360
|
const activeTickets = new Set();
|
|
359
|
-
const tickWatch = () => watchTick({ repoRoot, runId, cfg, readRun: loadRun, writeRow: ledger, running: activeTickets, exec: deps.watchExec, hooks: deps.watchHooks });
|
|
361
|
+
const tickWatch = () => watchTick({ repoRoot, runId, cfg, readRun: loadRun, writeRow: ledger, running: activeTickets, exec: deps.watchExec, hooks: deps.watchHooks, now: deps.watchNow });
|
|
360
362
|
|
|
361
363
|
/**
|
|
362
364
|
* The config ticket `id` is reviewed with (B35): its snapshot from the run record, re-read per
|
package/src/worker/queue.mjs
CHANGED
|
@@ -520,8 +520,9 @@ export function pendingTickets(repoRoot) {
|
|
|
520
520
|
return [];
|
|
521
521
|
}
|
|
522
522
|
const claimed = new Set(names.map((n) => CLAIM.exec(n)?.[1]));
|
|
523
|
+
const present = new Set(names);
|
|
523
524
|
const ids = names.filter((n) => /^[0-9a-f]{24}\.json$/.test(n)).map((n) => n.slice(0, 24));
|
|
524
|
-
const pending = ids.filter((id) => !
|
|
525
|
+
const pending = ids.filter((id) => !present.has(`${id}.done`) && !claimed.has(id));
|
|
525
526
|
const at = (/** @type {string} */ id) => {
|
|
526
527
|
const t = readJSON(path.join(queueDir(repoRoot), `${id}.json`));
|
|
527
528
|
return typeof t?.enqueued_at === 'string' ? t.enqueued_at : '';
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* (the engine saves it as a new seq and writes `review.state_restored`); an anchor with no body
|
|
26
26
|
* makes a missing file `lost` (refused, never a round 1). A
|
|
27
27
|
* `review.state` row of the run whose MAC does not verify is `tampered` for every block and file
|
|
28
|
-
* of it (`blockAnchors`). Whether
|
|
28
|
+
* of it (`blockAnchors`). Whether a file's one L3 rung was used comes from the anchors, never
|
|
29
29
|
* from the state files.
|
|
30
30
|
*
|
|
31
31
|
* B78: the reviewed text itself is kept as a git blob in the content root's object store
|
|
@@ -427,8 +427,8 @@ export async function saveState({ runId, block, file, key, state, seq, writeRow,
|
|
|
427
427
|
}
|
|
428
428
|
|
|
429
429
|
/**
|
|
430
|
-
* Whether the block
|
|
431
|
-
*
|
|
430
|
+
* Whether any file of the block has used its L3 rung (§3.6 rules 5/6).
|
|
431
|
+
* Derived from the ledger anchors only; fails closed: a block anchor that
|
|
432
432
|
* does not verify is `tampered`.
|
|
433
433
|
* @param {{runId: string, block: string, key: Buffer, rows: Array<Record<string, any>>}} opts
|
|
434
434
|
* @returns {{status: 'ok' | 'tampered', used: boolean}}
|
|
@@ -438,3 +438,15 @@ export function blockRungUsed(opts) {
|
|
|
438
438
|
if (anchors === null) return { status: 'tampered', used: true };
|
|
439
439
|
return { status: 'ok', used: anchors.some((a) => a.l3_rung_used === true) };
|
|
440
440
|
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Whether this file has used its L3 rung, once per file across all its anchors.
|
|
444
|
+
* Verify the run's anchors before filtering by file, preserving fail-closed tamper detection.
|
|
445
|
+
* @param {{runId: string, block: string, file: string, key: Buffer, rows: Array<Record<string, any>>}} opts
|
|
446
|
+
* @returns {{status: 'ok' | 'tampered', used: boolean}}
|
|
447
|
+
*/
|
|
448
|
+
export function fileRungUsed(opts) {
|
|
449
|
+
const anchors = blockAnchors(opts);
|
|
450
|
+
if (anchors === null) return { status: 'tampered', used: true };
|
|
451
|
+
return { status: 'ok', used: anchors.some((a) => a.file === opts.file && a.l3_rung_used === true) };
|
|
452
|
+
}
|
package/src/worker/watch.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { snapshotFor } from '../state/config-snapshot.mjs';
|
|
|
7
7
|
import { readRun } from '../state/run.mjs';
|
|
8
8
|
import { ownsAt } from '../review/gate-check.mjs';
|
|
9
9
|
import { exec } from '../util/exec.mjs';
|
|
10
|
+
import { withLock } from '../util/file-locks.mjs';
|
|
10
11
|
import { blockTickets, enqueueHash, isDone, queueDir, supersedeTicket } from './queue.mjs';
|
|
11
12
|
import { assertRowPath, DELETED_HASH, gitChildEnv, hashBytes } from './ticket.mjs';
|
|
12
13
|
|
|
@@ -30,97 +31,99 @@ export async function watchTick({ repoRoot, runId, cfg, readRun: loadRun = readR
|
|
|
30
31
|
const env = gitChildEnv();
|
|
31
32
|
for (const [block, entry] of Object.entries(record.blocks ?? {})) {
|
|
32
33
|
if (entry.status !== 'open' || !BLOCK.test(block)) continue;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
34
|
+
await withLock(`watch:${realpathSync(repoRoot)}\0${block}`, async () => {
|
|
35
|
+
const stateFile = path.join(queueDir(repoRoot), 'watch', `${block}.json`);
|
|
36
|
+
let tree = null;
|
|
37
|
+
let root = repoRoot;
|
|
38
|
+
let state = {};
|
|
39
|
+
try { state = JSON.parse(readFileSync(stateFile, 'utf8')) ?? {}; } catch { /* restart at base */ }
|
|
40
|
+
const base = entry.base_sha;
|
|
41
|
+
/** @param {Record<string, any>} next */
|
|
42
|
+
const save = (next) => {
|
|
43
|
+
mkdirSync(path.dirname(stateFile), { recursive: true });
|
|
44
|
+
const tmp = `${stateFile}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
45
|
+
try {
|
|
46
|
+
writeFileSync(tmp, `${JSON.stringify(next)}\n`, { mode: 0o600 });
|
|
47
|
+
renameSync(tmp, stateFile);
|
|
48
|
+
state = next;
|
|
49
|
+
} finally { rmSync(tmp, { force: true }); }
|
|
50
|
+
};
|
|
51
|
+
/** @param {string} reason */
|
|
52
|
+
const skip = async (reason) => {
|
|
53
|
+
const skipped = Array.isArray(state.skipped) ? state.skipped : [];
|
|
54
|
+
if (!skipped.includes(reason)) {
|
|
55
|
+
await writeRow({ event: 'watch.skipped', block, reason });
|
|
56
|
+
save({ ...state, skipped: [...skipped, reason] });
|
|
57
|
+
}
|
|
58
|
+
};
|
|
43
59
|
try {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
await
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
if (!/^100(?:644|755) blob /.test(listed.stdout)) { unreadable = true; break; }
|
|
105
|
-
const shown = await execute(['git', 'show', `${head}:${file}`], { cwd: root, env, timeoutMs: 10000, stdoutEncoding: 'base64' });
|
|
106
|
-
if (shown.result !== 'ok') { unreadable = true; break; }
|
|
107
|
-
hashes.push({ file, content_hash: hashBytes(Buffer.from(shown.stdout, 'base64')) });
|
|
108
|
-
}
|
|
109
|
-
if (unreadable) { await skip('unreadable_head'); continue; }
|
|
110
|
-
await hooks.beforeQueue?.();
|
|
111
|
-
for (const { file, content_hash } of hashes) {
|
|
112
|
-
const held = earlier.some((old) => old.file === file);
|
|
113
|
-
if (!held) enqueueHash({ repoRoot, run: runId, block, file, content_hash, tree });
|
|
114
|
-
for (const old of earlier) {
|
|
115
|
-
if (old.file === file && old.content_hash !== content_hash && !running.has(old.ticket) && !isDone(repoRoot, old.ticket)) supersedeTicket(repoRoot, old.ticket);
|
|
60
|
+
if (entry.worktree) {
|
|
61
|
+
root = realpathSync(entry.worktree.path);
|
|
62
|
+
tree = root;
|
|
63
|
+
}
|
|
64
|
+
if (state.run !== runId || state.base !== base || state.tree !== tree) state = { run: runId, base, tree, skipped: [] };
|
|
65
|
+
/** @param {string[]} argv */
|
|
66
|
+
const git = (argv) => execute(['git', ...argv], { cwd: root, env, timeoutMs: 10000 });
|
|
67
|
+
const headResult = await git(['rev-parse', '--verify', 'HEAD^{commit}']);
|
|
68
|
+
const head = headResult.stdout.trim();
|
|
69
|
+
if (headResult.result !== 'ok' || !SHA.test(head)) { await skip('unreadable_head'); return; }
|
|
70
|
+
const earlier = blockTickets({ repoRoot, run: runId, block, tree });
|
|
71
|
+
await hooks.afterHead?.(head);
|
|
72
|
+
if (typeof base !== 'string' || !SHA.test(base) || (await git(['merge-base', '--is-ancestor', base, head])).result !== 'ok') {
|
|
73
|
+
await skip('not_descendant'); return;
|
|
74
|
+
}
|
|
75
|
+
const last = typeof state.sha === 'string' && SHA.test(state.sha) ? state.sha : base;
|
|
76
|
+
const previous = typeof state.pending?.sha === 'string' && SHA.test(state.pending.sha) ? state.pending.sha : last;
|
|
77
|
+
if (previous !== base && (await git(['merge-base', '--is-ancestor', previous, head])).result !== 'ok') {
|
|
78
|
+
await skip('rewound');
|
|
79
|
+
const { sha: _sha, pending: _pending, burst_started_at: _burst, ...restart } = state;
|
|
80
|
+
save(restart);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
// A healthy tick ends the skip streak: no new commit here, or a queued commit below. An
|
|
84
|
+
// unreadable commit keeps the streak, so it writes one watch.skipped row, not one per tick.
|
|
85
|
+
if (last === head) { if (state.skipped?.length) save({ ...state, skipped: [] }); return; }
|
|
86
|
+
const time = now();
|
|
87
|
+
if (state.pending?.sha !== head) {
|
|
88
|
+
// A new HEAD restarts the quiet wait, but keeps the burst's original deadline.
|
|
89
|
+
save({ ...state, pending: { sha: head, seen_at: time }, burst_started_at: state.burst_started_at ?? time });
|
|
90
|
+
}
|
|
91
|
+
if (time - state.pending.seen_at < QUIET_MS && time - state.burst_started_at < MAX_BURST_MS) return;
|
|
92
|
+
const diff = await git(['diff', '--name-only', '-z', '--no-renames', last, head, '--']);
|
|
93
|
+
if (diff.result !== 'ok') { await skip('unreadable_head'); return; }
|
|
94
|
+
const owns = ownsAt(tree ? '' : path.relative(repoRoot, record.workspace).split(path.sep).join('/'));
|
|
95
|
+
const files = diff.stdout.split('\0').filter(Boolean);
|
|
96
|
+
const owned = files.filter((file) => owns(entry.owned_files, file, entry.owned_kinds));
|
|
97
|
+
if (owned.length !== files.length) await skip('unowned_path');
|
|
98
|
+
// Stage every hash before publishing anything: an unreadable object queues nothing.
|
|
99
|
+
const hashes = [];
|
|
100
|
+
let unreadable = false;
|
|
101
|
+
for (const raw of owned.filter((file) => reviewExcludeRule(snapshotFor(record, '')?.config ?? cfg, file) === null)) {
|
|
102
|
+
const file = assertRowPath(raw);
|
|
103
|
+
const listed = await git(['ls-tree', '-z', head, '--', file]);
|
|
104
|
+
if (listed.result !== 'ok') { unreadable = true; break; }
|
|
105
|
+
if (!listed.stdout) { hashes.push({ file, content_hash: DELETED_HASH }); continue; }
|
|
106
|
+
if (!/^100(?:644|755) blob /.test(listed.stdout)) { unreadable = true; break; }
|
|
107
|
+
const shown = await execute(['git', 'show', `${head}:${file}`], { cwd: root, env, timeoutMs: 10000, stdoutEncoding: 'base64' });
|
|
108
|
+
if (shown.result !== 'ok') { unreadable = true; break; }
|
|
109
|
+
hashes.push({ file, content_hash: hashBytes(Buffer.from(shown.stdout, 'base64')) });
|
|
110
|
+
}
|
|
111
|
+
if (unreadable) { await skip('unreadable_head'); return; }
|
|
112
|
+
await hooks.beforeQueue?.();
|
|
113
|
+
for (const { file, content_hash } of hashes) {
|
|
114
|
+
const held = earlier.some((old) => old.file === file);
|
|
115
|
+
if (!held) enqueueHash({ repoRoot, run: runId, block, file, content_hash, tree });
|
|
116
|
+
for (const old of earlier) {
|
|
117
|
+
if (old.file === file && old.content_hash !== content_hash && !running.has(old.ticket) && !isDone(repoRoot, old.ticket)) supersedeTicket(repoRoot, old.ticket);
|
|
118
|
+
}
|
|
119
|
+
if (held) await writeRow({ event: 'watch.held', block, file, content_hash });
|
|
116
120
|
}
|
|
117
|
-
|
|
121
|
+
const { pending: _pending, burst_started_at: _burst, ...queued } = state;
|
|
122
|
+
save({ ...queued, run: runId, base, tree, sha: head, skipped: [] });
|
|
123
|
+
} catch {
|
|
124
|
+
// A bad path, missing tree or failed git read is a skip, never a block failure.
|
|
125
|
+
await skip('unreadable_head');
|
|
118
126
|
}
|
|
119
|
-
|
|
120
|
-
save({ ...queued, run: runId, base, tree, sha: head, skipped: [] });
|
|
121
|
-
} catch {
|
|
122
|
-
// A bad path, missing tree or failed git read is a skip, never a block failure.
|
|
123
|
-
await skip('unreadable_head');
|
|
124
|
-
}
|
|
127
|
+
});
|
|
125
128
|
}
|
|
126
129
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { workerRegistry } from './queue.mjs';
|
|
2
|
+
import { repoRootOf } from './ticket.mjs';
|
|
3
|
+
import { checkWorkerPin, normalizeStartTime, processStartTime } from '../state/run.mjs';
|
|
4
|
+
|
|
5
|
+
/** @typedef {{pid: number, start_time: string, run: string}} WarningWorker */
|
|
6
|
+
/** @typedef {{live: boolean, worker: WarningWorker | null}} RegistryEntry */
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Read-only advice about live workers serving a run without a matching process identity pin.
|
|
10
|
+
* Failure to discover the repository, read its registry or probe a pin never breaks status.
|
|
11
|
+
* @param {Record<string, any>} record
|
|
12
|
+
* @param {{repoRoot?: string, registry?: (root: string) => RegistryEntry[] | Promise<RegistryEntry[]>, probe?: import('../state/run.mjs').StartTimeProbe}} [opts]
|
|
13
|
+
* @returns {Promise<string[]>}
|
|
14
|
+
*/
|
|
15
|
+
export async function unpinnedWorkerWarnings(record, { repoRoot, registry = workerRegistry, probe = processStartTime } = {}) {
|
|
16
|
+
try {
|
|
17
|
+
if (record.status === 'ended') return [];
|
|
18
|
+
const root = repoRoot ?? await repoRootOf(record.workspace);
|
|
19
|
+
const pins = record.workers ?? (record.worker ? [record.worker] : []);
|
|
20
|
+
const unpinned = (await registry(root)).filter(({ live, worker }) => live && worker?.run === record.run_id
|
|
21
|
+
&& !pins.some((pin) => pin.pid === worker.pid
|
|
22
|
+
&& normalizeStartTime(pin.started_at) === normalizeStartTime(worker.start_time)));
|
|
23
|
+
if (unpinned.length === 0) return [];
|
|
24
|
+
|
|
25
|
+
let suffix = '';
|
|
26
|
+
if (unpinned.length === 1 && pins.length > 0) {
|
|
27
|
+
const check = await checkWorkerPin(record, { probe });
|
|
28
|
+
if (!check.ok) suffix = ` (pinned pid ${pins[0].pid} is ${check.event === 'worker.replaced' ? 'replaced' : 'down'})`;
|
|
29
|
+
}
|
|
30
|
+
return unpinned.map(({ worker }) => `warning: live worker ${worker.pid} is not pinned: run start --reattach --run ${record.run_id}`
|
|
31
|
+
+ (unpinned.length > 1 ? ` --worker-pid ${worker.pid}` : suffix));
|
|
32
|
+
} catch {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
}
|