@codedology/code-forge 0.12.0 → 0.14.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 +53 -0
- package/README.md +28 -2
- package/docs/reference/blocks.json +354 -1
- package/docs/reference/config.md +4 -1
- package/package.json +1 -1
- package/schema/code-forge.schema.json +10 -1
- package/skill/references/adapters/claude-code.md +1 -1
- package/skill/references/adapters/solo.md +1 -1
- package/skill/references/code.md +15 -8
- package/skill/references/ledger.md +4 -2
- package/skill/references/proof.md +1 -1
- package/skill/references/review.md +21 -7
- package/skill/references/security.md +5 -3
- package/skill/scripts/forge +1 -1
- package/src/autopilot/actions.mjs +4 -5
- package/src/cli/block.mjs +130 -31
- package/src/cli/context.mjs +3 -2
- package/src/cli/report.mjs +2 -2
- package/src/cli/review-file.mjs +67 -42
- package/src/cli/review.mjs +2 -2
- package/src/context/pack.mjs +13 -9
- package/src/doctor/index.mjs +2 -1
- package/src/doctor/local.mjs +19 -0
- package/src/doctor/worker-probe.mjs +15 -2
- package/src/ledger/export.mjs +1 -1
- package/src/ledger/report.mjs +9 -3
- package/src/ledger/spend.mjs +3 -3
- package/src/proof/lock.mjs +5 -1
- package/src/review/accept-hit.mjs +78 -0
- package/src/review/agent-waiver.mjs +201 -0
- package/src/review/gate-check.mjs +62 -28
- package/src/review/reopen.mjs +35 -14
- package/src/review/schema-fallback.mjs +3 -3
- package/src/state/config-snapshot.mjs +1 -0
- package/src/util/file-locks.mjs +118 -0
- package/src/util/forbidden.mjs +51 -2
- package/src/worker/engine.mjs +2 -2
- package/src/worker/loop.mjs +83 -33
- package/src/worker/queue.mjs +141 -33
- package/src/worker/watch.mjs +4 -2
package/src/worker/queue.mjs
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* <repo>/.code-forge/queue/<ticket>.json the ticket (written by `review-file`, idempotent)
|
|
6
6
|
* <repo>/.code-forge/queue/<ticket>.done the worker's done marker `{ticket, status, result}`
|
|
7
7
|
* <repo>/.code-forge/queue/worker.json the worker's announcement `{pid, start_time, run, started_at, heartbeat_at}`
|
|
8
|
+
* <repo>/.code-forge/workers/<pid>-<start_time>.json each worker's private registry entry
|
|
8
9
|
* <repo>/.code-forge/reviews/<run>/<ticket>.json the SIGNED result (run key HMAC, §8.6)
|
|
9
10
|
*
|
|
10
11
|
* The forbidden list (§8.4 ★) denies a coder any write under `reviews/` and to `queue/*.done`;
|
|
@@ -28,7 +29,7 @@
|
|
|
28
29
|
*/
|
|
29
30
|
|
|
30
31
|
import { createHash, randomBytes } from 'node:crypto';
|
|
31
|
-
import { existsSync, linkSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
32
|
+
import { chmodSync, existsSync, linkSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
32
33
|
import path from 'node:path';
|
|
33
34
|
import { loadKey, signRow, verifyRow } from '../state/signer.mjs';
|
|
34
35
|
import { isAlive, readStartTime, UNKNOWN_START_TIME } from '../util/reaper.mjs';
|
|
@@ -44,15 +45,19 @@ export const HEARTBEAT_STALE_MS = 60_000;
|
|
|
44
45
|
|
|
45
46
|
/** @param {string} repoRoot */
|
|
46
47
|
export const queueDir = (repoRoot) => path.join(repoRoot, '.code-forge', 'queue');
|
|
48
|
+
/** @param {string} repoRoot */
|
|
49
|
+
const workersDir = (repoRoot) => path.join(repoRoot, '.code-forge', 'workers');
|
|
50
|
+
/** @param {string} repoRoot @param {{pid: number, start_time: string}} self */
|
|
51
|
+
const workerPath = (repoRoot, self) => path.join(workersDir(repoRoot), `${self.pid}-${self.start_time}.json`);
|
|
47
52
|
/** @param {string} repoRoot @param {string} runId */
|
|
48
53
|
export const reviewsDir = (repoRoot, runId) => path.join(repoRoot, '.code-forge', 'reviews', runId);
|
|
49
54
|
|
|
50
|
-
/** @param {string} file @param {string} text */
|
|
51
|
-
function writeAtomic(file, text) {
|
|
55
|
+
/** @param {string} file @param {string} text @param {number} [mode] */
|
|
56
|
+
function writeAtomic(file, text, mode = 0o644) {
|
|
52
57
|
mkdirSync(path.dirname(file), { recursive: true });
|
|
53
58
|
const tmp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
54
59
|
try {
|
|
55
|
-
writeFileSync(tmp, text, { mode
|
|
60
|
+
writeFileSync(tmp, text, { mode });
|
|
56
61
|
renameSync(tmp, file);
|
|
57
62
|
} catch (err) {
|
|
58
63
|
rmSync(tmp, { force: true });
|
|
@@ -295,11 +300,13 @@ export function clearDone({ repoRoot, run, block, file, tree = null }) {
|
|
|
295
300
|
* A crash between the drop and the caller's enqueue leaves a re-runnable ticket with no marker:
|
|
296
301
|
* harmless — its ticket file is pending, and the next `review-file` queues it anyway. Synchronous.
|
|
297
302
|
* @param {string} repoRoot @param {string} ticket @param {Buffer} seen
|
|
298
|
-
*
|
|
303
|
+
* `once` records the join's one retry before releasing the claim. An existing retry marker
|
|
304
|
+
* keeps the result, including when another join has already finished the rerun.
|
|
305
|
+
* @param {{once?: boolean, link?: (from: string, to: string) => void, read?: (file: string) => Buffer, rename?: (from: string, to: string) => void, remove?: (file: string) => void, write?: (file: string, text: string) => void}} [io] - for
|
|
299
306
|
* tests (default `linkSync`, `readFileSync`, `renameSync`, `rmSync` with force, `writeFileSync`
|
|
300
307
|
* with flag `wx` and mode 0o600). A claim that cannot be read is restored the same way and
|
|
301
308
|
* refused `fresh-restore-failed`.
|
|
302
|
-
* @returns {'dropped' | 'gone' | 'changed'}
|
|
309
|
+
* @returns {'dropped' | 'gone' | 'changed' | 'kept'}
|
|
303
310
|
* @throws {WorkerError} `fresh-claim-failed` (the claim could not be taken, or not removed after
|
|
304
311
|
* the drop; nothing was queued) or `fresh-restore-failed` (the message says which state was left:
|
|
305
312
|
* put back, a newer marker, a refused marker, the claim kept, or nothing). A message names an
|
|
@@ -307,6 +314,8 @@ export function clearDone({ repoRoot, run, block, file, tree = null }) {
|
|
|
307
314
|
*/
|
|
308
315
|
export function dropDoneIf(repoRoot, ticket, seen, io = {}) {
|
|
309
316
|
const marker = path.join(queueDir(repoRoot), `${assertTicketId(ticket)}.done`);
|
|
317
|
+
const retried = path.join(queueDir(repoRoot), `${ticket}.retried`);
|
|
318
|
+
if (io.once && existsSync(retried)) return 'kept';
|
|
310
319
|
const claim = `${marker}.${process.pid}.${randomBytes(4).toString('hex')}.fresh`;
|
|
311
320
|
const remove = io.remove ?? ((/** @type {string} */ file) => rmSync(file, { force: true }));
|
|
312
321
|
try {
|
|
@@ -324,6 +333,16 @@ export function dropDoneIf(repoRoot, ticket, seen, io = {}) {
|
|
|
324
333
|
} catch (err) {
|
|
325
334
|
readFailed = codeOf(err);
|
|
326
335
|
}
|
|
336
|
+
let retryFailed = null;
|
|
337
|
+
if (same && io.once) {
|
|
338
|
+
try {
|
|
339
|
+
writeFileSync(retried, `${ticket}\n`, { flag: 'wx', mode: 0o600 });
|
|
340
|
+
} catch (err) {
|
|
341
|
+
// Restore the claim before keeping or refusing it. Only one join can spend the retry.
|
|
342
|
+
retryFailed = codeOf(err);
|
|
343
|
+
same = false;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
327
346
|
if (same) {
|
|
328
347
|
try {
|
|
329
348
|
remove(claim);
|
|
@@ -350,6 +369,8 @@ export function dropDoneIf(repoRoot, ticket, seen, io = {}) {
|
|
|
350
369
|
// the marker is back (or a newer one won), so the ticket is done; `clearDone` removes the claim
|
|
351
370
|
}
|
|
352
371
|
if (readFailed !== null) throw new WorkerError('fresh-restore-failed', `${unread}; ${linkFailed === 'EEXIST' ? 'a newer marker is in place' : 'it was put back'}; nothing was queued`);
|
|
372
|
+
if (retryFailed === 'EEXIST') return 'kept';
|
|
373
|
+
if (retryFailed !== null) throw new WorkerError('fresh-claim-failed', `the retry marker of ticket ${ticket} could not be written (${retryFailed}); the done marker was restored; nothing was queued`);
|
|
353
374
|
return 'changed';
|
|
354
375
|
}
|
|
355
376
|
|
|
@@ -427,6 +448,36 @@ export function blockTickets({ repoRoot, run, block, tree = null }) {
|
|
|
427
448
|
.sort((a, b) => a.enqueued_at.localeCompare(b.enqueued_at) || a.ticket.localeCompare(b.ticket));
|
|
428
449
|
}
|
|
429
450
|
|
|
451
|
+
/**
|
|
452
|
+
* Atomically record the join's findings handoff once. Read only this marker, never a result.
|
|
453
|
+
* @param {{repoRoot: string, block: string, file: string, ticket: string}} opts
|
|
454
|
+
* @returns {boolean} true only for the join that first delivered the findings.
|
|
455
|
+
*/
|
|
456
|
+
export function deliverBlockFindings({ repoRoot, block, file, ticket }) {
|
|
457
|
+
const marker = path.join(queueDir(repoRoot), `${assertTicketId(ticket)}.delivered`);
|
|
458
|
+
const expected = { block, file, ticket };
|
|
459
|
+
const delivered = () => {
|
|
460
|
+
try {
|
|
461
|
+
const seen = JSON.parse(readFileSync(marker, 'utf8'));
|
|
462
|
+
if (seen?.block === block && seen.file === file && seen.ticket === ticket) return true;
|
|
463
|
+
} catch (err) {
|
|
464
|
+
if (err.code === 'ENOENT') return false;
|
|
465
|
+
}
|
|
466
|
+
throw new WorkerError('result-unreadable', 'a findings delivery marker cannot be read');
|
|
467
|
+
};
|
|
468
|
+
if (delivered()) return false;
|
|
469
|
+
const tmp = `${marker}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
470
|
+
try {
|
|
471
|
+
writeFileSync(tmp, `${JSON.stringify(expected)}\n`, { flag: 'wx' });
|
|
472
|
+
try { linkSync(tmp, marker); } catch (err) {
|
|
473
|
+
if (err.code !== 'EEXIST') throw err;
|
|
474
|
+
if (delivered()) return false;
|
|
475
|
+
throw new WorkerError('result-unreadable', 'a findings delivery marker disappeared');
|
|
476
|
+
}
|
|
477
|
+
return true;
|
|
478
|
+
} finally { rmSync(tmp, { force: true }); }
|
|
479
|
+
}
|
|
480
|
+
|
|
430
481
|
/**
|
|
431
482
|
* Read and re-validate a ticket (coder-writable): its id must match its content.
|
|
432
483
|
* @param {string} repoRoot @param {string} id
|
|
@@ -468,7 +519,8 @@ export function writeResult({ repoRoot, runId, ticket, result, key }) {
|
|
|
468
519
|
const signed = signRow({ ...result, run: runId, ticket: assertTicketId(ticket) }, key);
|
|
469
520
|
const file = path.join(reviewsDir(repoRoot, runId), `${ticket}.json`);
|
|
470
521
|
writeAtomic(file, `${JSON.stringify(signed)}\n`);
|
|
471
|
-
|
|
522
|
+
const openFindings = signed.status === 'reviewed' && signed.approved !== true && Array.isArray(signed.findings) && signed.findings.length > 0;
|
|
523
|
+
writeAtomic(path.join(queueDir(repoRoot), `${ticket}.done`), `${JSON.stringify({ ticket, status: signed.status, ...(openFindings ? { open_findings: true } : {}), result: path.relative(repoRoot, file) })}\n`);
|
|
472
524
|
return signed;
|
|
473
525
|
}
|
|
474
526
|
|
|
@@ -497,20 +549,25 @@ export async function verifyResult(repoRoot, runId, ticket) {
|
|
|
497
549
|
}
|
|
498
550
|
|
|
499
551
|
/**
|
|
500
|
-
* Announce the serving worker in the
|
|
552
|
+
* Announce the serving worker in the private registry and legacy queue file.
|
|
501
553
|
* @param {string} repoRoot @param {{pid: number, run: string}} who
|
|
502
554
|
*/
|
|
503
555
|
export function announceWorker(repoRoot, { pid, run }) {
|
|
504
556
|
const now = new Date().toISOString();
|
|
505
557
|
const body = { pid, start_time: readStartTime(pid) ?? UNKNOWN_START_TIME, run, started_at: now, heartbeat_at: now };
|
|
506
|
-
|
|
558
|
+
mkdirSync(workersDir(repoRoot), { recursive: true, mode: 0o700 });
|
|
559
|
+
chmodSync(workersDir(repoRoot), 0o700);
|
|
560
|
+
writeAtomic(workerPath(repoRoot, body), `${JSON.stringify(body)}\n`, 0o600);
|
|
561
|
+
const legacy = readWorker(repoRoot);
|
|
562
|
+
if (!legacy || !workerLive(legacy)) writeAtomic(path.join(queueDir(repoRoot), WORKER_FILE), `${JSON.stringify(body)}\n`);
|
|
507
563
|
return body;
|
|
508
564
|
}
|
|
509
565
|
|
|
510
566
|
/**
|
|
511
|
-
* Refresh
|
|
567
|
+
* Refresh both announcements' `heartbeat_at` (B30) while each is still THIS worker's: each file is
|
|
512
568
|
* re-read right before the write and must name the same `pid` AND `start_time` the worker
|
|
513
|
-
* announced. Another worker's announcement is never overwritten
|
|
569
|
+
* announced. Another worker's announcement is never overwritten. A worker keeps beating its
|
|
570
|
+
* registry entry even when another worker owns worker.json.
|
|
514
571
|
* The beat is written to a temp file, the announcement re-read and compared once more, and only
|
|
515
572
|
* then renamed over it. The worker calls this from a timer of its own, so it keeps beating while
|
|
516
573
|
* a long review session is awaited.
|
|
@@ -520,23 +577,23 @@ export function announceWorker(repoRoot, { pid, run }) {
|
|
|
520
577
|
*/
|
|
521
578
|
export function beatWorker(repoRoot, self, now = new Date()) {
|
|
522
579
|
const mine = (/** @type {Record<string, any> | null} */ w) => w?.pid === self.pid && w.start_time === self.start_time;
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
const file
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
580
|
+
let written = false;
|
|
581
|
+
const registryFile = workerPath(repoRoot, self);
|
|
582
|
+
for (const file of [registryFile, path.join(queueDir(repoRoot), WORKER_FILE)]) {
|
|
583
|
+
const w = readJSON(file);
|
|
584
|
+
if (!mine(w)) continue;
|
|
585
|
+
const tmp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
586
|
+
try {
|
|
587
|
+
writeFileSync(tmp, `${JSON.stringify({ ...w, heartbeat_at: now.toISOString() })}\n`, { mode: file === registryFile ? 0o600 : 0o644 });
|
|
588
|
+
// Re-read before rename; another owner's announcement is never replaced.
|
|
589
|
+
if (!mine(readJSON(file))) continue;
|
|
590
|
+
renameSync(tmp, file);
|
|
591
|
+
written = true;
|
|
592
|
+
} finally {
|
|
531
593
|
rmSync(tmp, { force: true });
|
|
532
|
-
return false;
|
|
533
594
|
}
|
|
534
|
-
renameSync(tmp, file);
|
|
535
|
-
return true;
|
|
536
|
-
} catch (err) {
|
|
537
|
-
rmSync(tmp, { force: true });
|
|
538
|
-
throw err;
|
|
539
595
|
}
|
|
596
|
+
return written;
|
|
540
597
|
}
|
|
541
598
|
|
|
542
599
|
/** A heartbeat further than this in the future is not fresh (clock skew tolerance). */
|
|
@@ -560,9 +617,59 @@ export function readWorker(repoRoot) {
|
|
|
560
617
|
return w && Number.isInteger(w.pid) && w.pid > 1 && typeof w.run === 'string' ? w : null;
|
|
561
618
|
}
|
|
562
619
|
|
|
563
|
-
/** Remove
|
|
564
|
-
export function retractWorker(repoRoot, pid) {
|
|
565
|
-
|
|
620
|
+
/** Remove only this owner's announcements. @param {string} repoRoot @param {number} pid @param {string} [startTime] */
|
|
621
|
+
export function retractWorker(repoRoot, pid, startTime) {
|
|
622
|
+
const mine = (w) => w?.pid === pid && (startTime === undefined || w.start_time === startTime);
|
|
623
|
+
for (const entry of workerRegistry(repoRoot)) {
|
|
624
|
+
if (mine(entry.worker)) rmSync(entry.file, { force: true });
|
|
625
|
+
}
|
|
626
|
+
if (mine(readWorker(repoRoot))) rmSync(path.join(queueDir(repoRoot), WORKER_FILE), { force: true });
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** @typedef {{pid: number, start_time: string, run: string, started_at: string, heartbeat_at: string}} WorkerAnnouncement */
|
|
630
|
+
|
|
631
|
+
/** Bad entries remain occupied until a human fixes them. @param {any} w @returns {w is WorkerAnnouncement} */
|
|
632
|
+
function workerShape(w) {
|
|
633
|
+
return w && Number.isInteger(w.pid) && w.pid > 1 && typeof w.start_time === 'string' && w.start_time.length > 0
|
|
634
|
+
&& !/[\\/]/.test(w.start_time) && typeof w.run === 'string' && w.run.length > 0
|
|
635
|
+
&& typeof w.started_at === 'string' && Number.isFinite(Date.parse(w.started_at))
|
|
636
|
+
&& typeof w.heartbeat_at === 'string' && Number.isFinite(Date.parse(w.heartbeat_at));
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** @param {{pid: number, start_time: string, heartbeat_at?: string}} w @param {number} [nowMs] */
|
|
640
|
+
function workerLive(w, nowMs) {
|
|
641
|
+
if (!isAlive(w.pid)) return false;
|
|
642
|
+
if (heartbeatFresh(w, nowMs)) return true;
|
|
643
|
+
return w.start_time === UNKNOWN_START_TIME || readStartTime(w.pid) === w.start_time;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** @param {{pid: number, start_time: string, heartbeat_at?: string}} w @param {number} [nowMs] */
|
|
647
|
+
function workerLiveAtBoot(w, nowMs) {
|
|
648
|
+
if (!isAlive(w.pid)) return false;
|
|
649
|
+
const startTime = readStartTime(w.pid);
|
|
650
|
+
// Boot trusts orchestrator ps; sandbox readers let fresh heartbeats mask unreliable ps.
|
|
651
|
+
if (startTime !== null && w.start_time !== UNKNOWN_START_TIME) return startTime === w.start_time;
|
|
652
|
+
return heartbeatFresh(w, nowMs) || w.start_time === UNKNOWN_START_TIME;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** Read all entries, keeping unreadable/bad entries occupied. Only proven stale owners may be pruned.
|
|
656
|
+
* @param {string} repoRoot @param {{prune?: boolean, nowMs?: number}} [opts]
|
|
657
|
+
* @returns {Array<{file: string, worker: WorkerAnnouncement | null, live: boolean}>}
|
|
658
|
+
*/
|
|
659
|
+
export function workerRegistry(repoRoot, opts = {}) {
|
|
660
|
+
let names;
|
|
661
|
+
try { names = readdirSync(workersDir(repoRoot)); }
|
|
662
|
+
catch (err) { if (err.code === 'ENOENT') return []; throw err; }
|
|
663
|
+
const entries = [];
|
|
664
|
+
for (const name of names.sort().filter((n) => n.endsWith('.json'))) {
|
|
665
|
+
const file = path.join(workersDir(repoRoot), name);
|
|
666
|
+
const w = readJSON(file);
|
|
667
|
+
const worker = workerShape(w) && name === `${w.pid}-${w.start_time}.json` ? w : null;
|
|
668
|
+
const live = worker === null || (opts.prune ? workerLiveAtBoot(worker, opts.nowMs) : workerLive(worker, opts.nowMs));
|
|
669
|
+
if (!live && opts.prune) rmSync(file, { force: true });
|
|
670
|
+
else entries.push({ file, worker, live });
|
|
671
|
+
}
|
|
672
|
+
return entries;
|
|
566
673
|
}
|
|
567
674
|
|
|
568
675
|
/**
|
|
@@ -574,13 +681,14 @@ export function retractWorker(repoRoot, pid) {
|
|
|
574
681
|
* Accepted trade-off: a worker that is SIGKILLed (no retraction) leaves a heartbeat that stays
|
|
575
682
|
* fresh for up to `HEARTBEAT_STALE_MS` (60 s); if the OS reuses its pid inside that window, the
|
|
576
683
|
* queue reads as served until the heartbeat goes stale. A clean stop retracts the file at once.
|
|
577
|
-
*
|
|
684
|
+
* Registry entries take precedence; worker.json supports workers from older versions.
|
|
685
|
+
* @param {string} repoRoot @param {{nowMs?: number, pid?: number, registeredOnly?: boolean}} [opts]
|
|
578
686
|
*/
|
|
579
687
|
export function liveWorker(repoRoot, opts = {}) {
|
|
688
|
+
const entry = workerRegistry(repoRoot, opts).find((e) => e.live && e.worker && (opts.pid === undefined || e.worker.pid === opts.pid));
|
|
689
|
+
if (entry) return entry.worker;
|
|
690
|
+
if (opts.registeredOnly) return null;
|
|
580
691
|
const w = readWorker(repoRoot);
|
|
581
|
-
|
|
582
|
-
if (heartbeatFresh(w, opts.nowMs)) return w;
|
|
583
|
-
if (w.start_time !== UNKNOWN_START_TIME && readStartTime(w.pid) !== w.start_time) return null;
|
|
584
|
-
return w;
|
|
692
|
+
return w && (opts.pid === undefined || w.pid === opts.pid) && workerLive(w, opts.nowMs) ? w : null;
|
|
585
693
|
}
|
|
586
694
|
|
package/src/worker/watch.mjs
CHANGED
|
@@ -99,10 +99,12 @@ export async function watchTick({ repoRoot, runId, cfg, readRun: loadRun = readR
|
|
|
99
99
|
if (unreadable) { await skip('unreadable_head'); continue; }
|
|
100
100
|
await hooks.beforeQueue?.();
|
|
101
101
|
for (const { file, content_hash } of hashes) {
|
|
102
|
-
const
|
|
102
|
+
const held = earlier.some((old) => old.file === file);
|
|
103
|
+
if (!held) enqueueHash({ repoRoot, run: runId, block, file, content_hash, tree });
|
|
103
104
|
for (const old of earlier) {
|
|
104
|
-
if (old.file === file && old.
|
|
105
|
+
if (old.file === file && old.content_hash !== content_hash && !running.has(old.ticket) && !isDone(repoRoot, old.ticket)) supersedeTicket(repoRoot, old.ticket);
|
|
105
106
|
}
|
|
107
|
+
if (held) await writeRow({ event: 'watch.held', block, file, content_hash });
|
|
106
108
|
}
|
|
107
109
|
save({ ...state, run: runId, base, tree, sha: head, skipped: [] });
|
|
108
110
|
} catch {
|