@codedology/code-forge 0.11.0 → 0.13.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +30 -2
  3. package/docs/reference/blocks.json +359 -1
  4. package/docs/reference/config.md +7 -5
  5. package/package.json +2 -2
  6. package/schema/code-forge.schema.json +4 -2
  7. package/skill/SKILL.md +2 -0
  8. package/skill/references/code.md +19 -5
  9. package/skill/references/proof.md +8 -2
  10. package/skill/references/review.md +16 -7
  11. package/skill/references/rules-core.md +1 -1
  12. package/skill/scripts/forge +1 -1
  13. package/skill/templates/coder-brief.md +15 -3
  14. package/src/autopilot/actions.mjs +4 -5
  15. package/src/cli/block.mjs +41 -0
  16. package/src/cli/context.mjs +3 -2
  17. package/src/cli/gates.mjs +15 -6
  18. package/src/cli/review-file.mjs +114 -3
  19. package/src/cli/review.mjs +2 -2
  20. package/src/config/validate.mjs +12 -5
  21. package/src/context/pack.mjs +20 -13
  22. package/src/doctor/index.mjs +2 -1
  23. package/src/doctor/local.mjs +19 -0
  24. package/src/doctor/worker-probe.mjs +15 -2
  25. package/src/gates/expand.mjs +102 -9
  26. package/src/gates/run.mjs +6 -4
  27. package/src/install/wizard/flags.mjs +1 -1
  28. package/src/ledger/spend.mjs +3 -3
  29. package/src/proof/lock.mjs +27 -10
  30. package/src/proof/rule.mjs +9 -0
  31. package/src/proof/tiers.mjs +2 -14
  32. package/src/review/fixloop.mjs +11 -6
  33. package/src/review/gate-check.mjs +34 -9
  34. package/src/review/lenses/A.md +4 -0
  35. package/src/review/lenses/B.md +4 -0
  36. package/src/review/lenses/full.md +4 -0
  37. package/src/review/lenses/judge.md +4 -0
  38. package/src/review/lenses/quick.md +4 -0
  39. package/src/review/lenses/recheck.md +4 -0
  40. package/src/review/paired.mjs +1 -0
  41. package/src/review/reopen.mjs +18 -6
  42. package/src/review/schema-fallback.mjs +3 -3
  43. package/src/util/exec.mjs +2 -1
  44. package/src/util/file-locks.mjs +118 -0
  45. package/src/util/forbidden.mjs +29 -2
  46. package/src/util/gate-token.mjs +3 -0
  47. package/src/worker/engine.mjs +2 -2
  48. package/src/worker/loop.mjs +98 -33
  49. package/src/worker/queue.mjs +183 -33
  50. package/src/worker/watch.mjs +115 -0
@@ -790,6 +790,10 @@ export function renderForCodex(list = FORBIDDEN) {
790
790
  * `$ cat ~/.code-forge/runs/r.key`, `Write(.code-forge/reviews/x.json)`, a stream-json
791
791
  * `"name":"Write"` followed by the path — is found. It is a grep: a line that merely MENTIONS a
792
792
  * forbidden command is a hit too (fail closed; the block gate reports it as `rule_break`, B5).
793
+ * Codex `exec --json` logs (every non-empty line an object with a string `type`, first event
794
+ * `thread.started`) scan command_execution commands and file_change paths as writes. Other
795
+ * items scan whole, except agent_message and reasoning; a command_execution's aggregated_output
796
+ * is never scanned; non-item events are skipped. Any other log uses the plain-text grep above, unchanged.
793
797
  *
794
798
  * Linear in the line length: `contains` and `path` entries are evaluated ONCE per line over its
795
799
  * tokens (a `write` path entry hits when a write command starts before a token naming the path);
@@ -801,6 +805,17 @@ export function renderForCodex(list = FORBIDDEN) {
801
805
  * @returns {{id: string, line: number}[]}
802
806
  */
803
807
  export function scanTranscript(text, list = FORBIDDEN) {
808
+ const lines = String(text).split('\n');
809
+ /** @type {any[] | null} */
810
+ let events = null;
811
+ try {
812
+ const parsed = lines.map((line) => line.trim() === '' ? undefined : JSON.parse(line));
813
+ const nonEmpty = parsed.filter((event) => event !== undefined);
814
+ if (nonEmpty[0]?.type === 'thread.started' && nonEmpty.every((event) =>
815
+ event !== null && typeof event === 'object' && !Array.isArray(event) && typeof event.type === 'string')) events = parsed;
816
+ } catch {
817
+ // One bad JSONL line makes the entire transcript a plain-text, fail-closed grep.
818
+ }
804
819
  const commandEntries = list.filter((e) => e.kind === 'prefix' || e.kind === 'commandFlag' || e.kind === 'anyPrefix');
805
820
  const firstWords = new Set(['npx', 'bunx', 'pnpm', 'yarn', 'node']);
806
821
  for (const e of commandEntries) {
@@ -811,9 +826,21 @@ export function scanTranscript(text, list = FORBIDDEN) {
811
826
  const writeStarts = new Set([...WRITE_VERBS, ...WRAPPERS, 'sed', 'perl', 'git']);
812
827
  /** @type {{id: string, line: number}[]} */
813
828
  const hits = [];
814
- String(text)
815
- .split('\n')
829
+ lines
816
830
  .forEach((lineText, index) => {
831
+ if (events !== null) {
832
+ const event = events[index];
833
+ if (!event?.type.startsWith('item.') || !event.item) return;
834
+ const item = event.item;
835
+ if (item.type === 'agent_message' || item.type === 'reasoning') return;
836
+ // a command that is not a string is scanned with the rest of its item (fail closed), its output left out
837
+ if (item.type === 'command_execution') lineText = typeof item.command === 'string' ? item.command : JSON.stringify({ ...item, aggregated_output: undefined });
838
+ else if (item.type === 'file_change') {
839
+ lineText = (Array.isArray(item.changes) ? item.changes : [])
840
+ .filter((change) => typeof change?.path === 'string')
841
+ .map((change) => `Write(${JSON.stringify(change.path)})`).join(' ');
842
+ } else lineText = JSON.stringify(item);
843
+ }
817
844
  const tokens = lineText.split(/(>>?)|[\s'"`;|&(),{}[\]]+/).filter((t) => typeof t === 'string' && t.length > 0);
818
845
  const ids = new Set();
819
846
  let firstWrite = -1;
@@ -6,3 +6,6 @@
6
6
 
7
7
  /** A gate argv item that is EXACTLY this string expands to the block's test files. */
8
8
  export const TEST_FILES_TOKEN = '{test_files}';
9
+
10
+ /** A whole argv item for lint/format paths, never a command. */
11
+ export const CHANGED_FILES_TOKEN = '{changed_files}';
@@ -107,7 +107,7 @@ import { blockTree, worktreeRecord } from '../proof/worktree.mjs';
107
107
  import { ledgerSchemaHistory } from '../review/schema-fallback.mjs';
108
108
  import { verifyRow } from '../state/signer.mjs';
109
109
  import { redact } from '../util/redact.mjs';
110
- import { keyedLock } from '../util/locks.mjs';
110
+ import { withLock } from '../util/file-locks.mjs';
111
111
  import { blockAnchors, blockRungUsed, loadState, RESTORED_EVENT, saveState } from './review-state.mjs';
112
112
  import { assertAnswers } from './queue.mjs';
113
113
  import { assertTicketId, contentHash, readRegularFileNoFollow, WorkerError } from './ticket.mjs';
@@ -536,7 +536,7 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, block
536
536
  if (!ctx.readRows || !ctx.writeRow) return { status: 'unavailable', reason: 'no-ledger', approved: false, engine: 'adaptive', sessions: [] };
537
537
  const readRows = ctx.readRows;
538
538
  /** @type {{outcome: ReviewOutcome} | {round: () => Promise<ReviewOutcome>}} */
539
- const prepared = await keyedLock(blockLockKey(ctx.runId, ticket.block), async () => {
539
+ const prepared = await withLock(blockLockKey(ctx.runId, ticket.block), async () => {
540
540
  let rows;
541
541
  try {
542
542
  rows = (await readRows()).filter((r) => r?.run === ctx.runId);
@@ -21,8 +21,9 @@
21
21
  * snapshots (or none) keeps the config the worker booted with.
22
22
  *
23
23
  * Ticket pool (B42): the worker runs up to `review.parallel_tickets` (default 3, 1–16) tickets at
24
- * once, oldest first; when one ends the next pending one starts. Two tickets for the same
25
- * (block, file) never overlap (a file lock; the later one stays pending without taking a slot),
24
+ * once, oldest first (or fewest running per block with `review.fair_share`); when one ends the
25
+ * next pending one starts. Two tickets for the same (block, file) never overlap (a file lock;
26
+ * the later one stays pending without taking a slot),
26
27
  * and the engine takes the block's budget row and L3 rung under a block lock (`engine.mjs`). A
27
28
  * throw in one ticket is that ticket's `unavailable` result and never stops the others. `stop()`
28
29
  * starts no new ticket and waits for the running ones. With 1 the worker runs exactly as before.
@@ -49,14 +50,15 @@ import { runRecordPath } from '../state/paths.mjs';
49
50
  import { readRun, reattachWorker } from '../state/run.mjs';
50
51
  import { loadKey, signRow } from '../state/signer.mjs';
51
52
  import { logWarning } from '../util/error-log.mjs';
52
- import { keyedLock } from '../util/locks.mjs';
53
+ import { withLock, tryLock } from '../util/file-locks.mjs';
53
54
  import { redact, writeSafe } from '../util/redact.mjs';
54
55
  import { runRoot, setRunRoot } from '../util/tmp.mjs';
55
56
  import { openFindings } from '../review/gate-check.mjs';
56
57
  import { countLines, RouteError, routeTicket } from '../review/route.mjs';
57
58
  import { canonicalPath, contentRootFor, reviewTicket } from './engine.mjs';
58
- import { announceWorker, beatWorker, HEARTBEAT_MS, liveWorker, pendingTickets, readTicket, retractWorker, writeResult } from './queue.mjs';
59
+ import { announceWorker, beatWorker, HEARTBEAT_MS, isDone, liveWorker, pendingTickets, readTicket, retractWorker, workerRegistry, writeResult } from './queue.mjs';
59
60
  import { DELETED_HASH, hashBytes, readRegularFileNoFollow, repoRootOf, WorkerError } from './ticket.mjs';
61
+ import { watchTick } from './watch.mjs';
60
62
 
61
63
  /** Name of the key the worker resolves for System 1 (`keys.jev` / `system1.key` reference). */
62
64
  export const JEV_KEY_NAME = 'jev';
@@ -233,7 +235,10 @@ export function recordedVerdict(result) {
233
235
  * @property {typeof reviewTicket} [review] - the engine hook (default `./engine.mjs`).
234
236
  * @property {typeof spawnSession} [spawn]
235
237
  * @property {typeof readRun} [readRun] - the run-record read behind the per-ticket config (a test seam).
238
+ * @property {{beforeLock?: () => void, afterCount?: (count: number) => Promise<unknown>}} [hooks] - deterministic boot races.
236
239
  * @property {typeof readTicket} [readTicket] - the ticket read (a test seam).
240
+ * @property {typeof import('../util/exec.mjs').exec} [watchExec] - watch git exec seam.
241
+ * @property {Parameters<typeof watchTick>[0]['hooks']} [watchHooks] - deterministic watch races.
237
242
  * @property {import('../keys/store.mjs').KeyStore} [store] - default: B2's production store.
238
243
  * @property {any} [opRead]
239
244
  * @property {NodeJS.ProcessEnv} [env]
@@ -274,6 +279,8 @@ export async function createWorker(opts, deps = {}) {
274
279
  let stopping = false;
275
280
  /** @type {ReturnType<typeof setInterval> | null} */
276
281
  let heartbeat = null;
282
+ /** @type {ReturnType<typeof announceWorker> | null} */
283
+ let self = null;
277
284
  /** @type {(() => void) | null} */
278
285
  let wake = null;
279
286
  /** @type {(() => void) | null} wakes the pool's wait in `drain` (stop) */
@@ -281,6 +288,9 @@ export async function createWorker(opts, deps = {}) {
281
288
 
282
289
  /** @param {Record<string, any>} row */
283
290
  const ledger = (row) => writeRow(signRow({ run: runId, ...row }, key));
291
+ // Shared across direct ticks and the scheduler, so running tickets are never superseded.
292
+ const activeTickets = new Set();
293
+ const tickWatch = () => watchTick({ repoRoot, runId, cfg, readRun: loadRun, writeRow: ledger, running: activeTickets, exec: deps.watchExec, hooks: deps.watchHooks });
284
294
 
285
295
  /**
286
296
  * The config ticket `id` is reviewed with (B35): its snapshot from the run record, re-read per
@@ -410,7 +420,7 @@ export async function createWorker(opts, deps = {}) {
410
420
  // read, advanced and saved by one ticket at a time; the later one waits here. The key
411
421
  // carries the run so two workers in one process (tests) never share a lock.
412
422
  const t = ticket;
413
- result = await keyedLock(`${runId}\0${fileLockKey(t.block, t.file)}`, () => reviewOne(id, t));
423
+ result = await withLock(`${runId}\0${fileLockKey(t.block, t.file)}`, () => reviewOne(id, t));
414
424
  }
415
425
  // Stopping: a ticket whose session our own stop killed ends `unavailable` — leave it queued
416
426
  // (no done marker) for the next worker instead of recording the kill as a failure. A ticket
@@ -526,50 +536,50 @@ export async function createWorker(opts, deps = {}) {
526
536
  }
527
537
 
528
538
  /**
529
- * B42: how many tickets run at once — `review.parallel_tickets` of the snapshot in force (so a
530
- * `run reload` resizes the pool at the next drain), else of the boot config; anything but an
531
- * integer 1–16 is the default.
532
- * @returns {Promise<number>}
539
+ * Pool settings from the snapshot in force, else the boot config. A run reload applies at the
540
+ * next drain; anything but an integer 1–16 uses the default size.
541
+ * @returns {Promise<{limit: number, fairShare: boolean}>}
533
542
  */
534
- async function poolSize() {
543
+ async function poolSettings() {
535
544
  let current = cfg;
536
545
  try {
537
546
  current = await configFor('');
538
547
  } catch {
539
- // an unreadable record: the boot config's size (each ticket still fails closed on its own)
548
+ // an unreadable record: the boot settings (each ticket still fails closed on its own)
540
549
  }
541
- return parallelTickets(current);
550
+ return { limit: parallelTickets(current), fairShare: current?.review?.fair_share === true };
542
551
  }
543
552
 
544
553
  /**
545
- * The (block, file) key of each ticket read so far, by id: a written ticket never changes (its
546
- * id is its content's hash), so it is read once for scheduling, however many wake-ups it waits.
554
+ * The block and (block, file) key of each ticket read so far, by id. A written ticket never
555
+ * changes (its id is its content's hash), so scheduling reads it once across wake-ups.
547
556
  * Dropped once the ticket is done.
548
- * @type {Map<string, string>}
557
+ * @type {Map<string, {key: string, block: string}>}
549
558
  */
550
559
  const fileKeys = new Map();
551
560
 
552
561
  /**
553
- * The (block, file) a pending ticket names, or null when it cannot be read (it is started
562
+ * The block and file-lock key a pending ticket names, or null when unreadable (it is started
554
563
  * anyway and refused by `processTicket`; a null is not cached). Only a scheduling hint:
555
564
  * `processTicket` takes the file lock on the ticket it actually read.
556
- * @param {string} id @returns {string | null}
565
+ * @param {string} id @returns {{key: string, block: string} | null}
557
566
  */
558
567
  function fileKeyOf(id) {
559
568
  const known = fileKeys.get(id);
560
569
  if (known !== undefined) return known;
561
570
  try {
562
571
  const t = loadTicket(repoRoot, id);
563
- const key = fileLockKey(t.block, t.file);
564
- fileKeys.set(id, key);
565
- return key;
572
+ const file = { key: fileLockKey(t.block, t.file), block: t.block };
573
+ fileKeys.set(id, file);
574
+ return file;
566
575
  } catch {
567
576
  return null;
568
577
  }
569
578
  }
570
579
 
571
580
  /**
572
- * Serve every pending ticket (B42): up to `review.parallel_tickets` at once, oldest first; when
581
+ * Serve every pending ticket (B42): up to `review.parallel_tickets` at once, oldest first; with
582
+ * fair share, each slot goes to the block with fewest running tickets, ties oldest first. When
573
583
  * one finishes, the queue is re-read and the next pending ticket starts (no batch barrier; the
574
584
  * queue is also re-read every `pollMs` while tickets run, so a new ticket fills a free slot).
575
585
  * A ticket whose (block, file) is already running is left pending (it does not take a slot)
@@ -594,11 +604,14 @@ export async function createWorker(opts, deps = {}) {
594
604
  }
595
605
  /** @type {number | null} */
596
606
  let limit = null;
607
+ let fairShare = false;
597
608
  let done = 0;
598
609
  /** @type {Map<string, Promise<void>>} */
599
610
  const running = new Map();
600
611
  /** @type {Set<string>} the (block, file) keys of the running tickets */
601
612
  const busy = new Set();
613
+ /** @type {Map<string, number>} running tickets per block */
614
+ const runningByBlock = new Map();
602
615
  /** @type {Set<string>} */
603
616
  const started = new Set();
604
617
  for (;;) {
@@ -617,23 +630,61 @@ export async function createWorker(opts, deps = {}) {
617
630
  }
618
631
  }
619
632
  if (!stopping) {
620
- for (const id of pendingTickets(repoRoot)) {
621
- if (poisoned.has(id) || started.has(id)) continue;
622
- if (limit === null) limit = await poolSize();
623
- if (running.size >= limit || stopping) break;
624
- const fileKey = fileKeyOf(id);
625
- if (fileKey !== null && busy.has(fileKey)) continue; // waits without a slot
633
+ try {
634
+ await tickWatch();
635
+ } catch (thrown) {
636
+ const text = `watch tick failed (${errorKind(thrown)})`;
637
+ writeSafe(warnStream, `worker: WARN ${text}\n`);
638
+ try { await logWarning({ warning: 'watch_tick_failed', message: text }); } catch { /* a log failure never stops dispatch */ }
639
+ }
640
+ const pending = pendingTickets(repoRoot).filter((id) => !poisoned.has(id) && !started.has(id));
641
+ if (pending.length > 0 && limit === null) ({ limit, fairShare } = await poolSettings());
642
+ // Cache even unreadable hints for this tick: ranking never reads a ticket twice.
643
+ /** @type {Map<string, ReturnType<typeof fileKeyOf>>} */
644
+ const hints = new Map();
645
+ while (pending.length > 0 && running.size < limit && !stopping) {
646
+ let next = -1;
647
+ let fewest = Infinity;
648
+ for (let i = 0; i < pending.length; i += 1) {
649
+ const id = pending[i];
650
+ if (!hints.has(id)) hints.set(id, fileKeyOf(id));
651
+ const file = hints.get(id);
652
+ if (file && busy.has(file.key)) continue; // waits without a slot
653
+ const count = fairShare && file ? runningByBlock.get(file.block) ?? 0 : 0;
654
+ if (count < fewest) {
655
+ next = i;
656
+ fewest = count;
657
+ }
658
+ if (count === 0) break; // best possible count; ties stay oldest first
659
+ }
660
+ if (next === -1) break;
661
+ const [id] = pending.splice(next, 1);
662
+ const claim = await tryLock(`ticket\0${runId}\0${id}`);
663
+ if (!claim.ok) continue; // another process owns it; no pool slot is taken
664
+ // Acquisition awaited a process probe: another worker may have completed this ticket
665
+ // since the pending snapshot, or stop() may have arrived while we waited.
666
+ if (stopping || isDone(repoRoot, id)) {
667
+ claim.release();
668
+ continue;
669
+ }
670
+ const file = hints.get(id);
671
+ const fileKey = file?.key ?? null;
626
672
  started.add(id);
673
+ activeTickets.add(id);
627
674
  if (fileKey !== null) busy.add(fileKey);
675
+ if (file) runningByBlock.set(file.block, (runningByBlock.get(file.block) ?? 0) + 1);
628
676
  const slot = runOne(id)
629
677
  .then((n) => {
630
678
  done += n;
631
679
  })
632
680
  .finally(() => {
681
+ claim.release(); // the done marker is published before the claim is released
633
682
  // the slot is freed on every exit path (`runOne` never rejects)
634
683
  running.delete(id);
684
+ activeTickets.delete(id);
635
685
  fileKeys.delete(id);
636
686
  if (fileKey !== null) busy.delete(fileKey);
687
+ if (file) runningByBlock.set(file.block, runningByBlock.get(file.block) - 1);
637
688
  });
638
689
  running.set(id, slot);
639
690
  }
@@ -669,7 +720,8 @@ export async function createWorker(opts, deps = {}) {
669
720
  * must still see the worker as live the whole time.
670
721
  */
671
722
  function announce() {
672
- const self = announceWorker(repoRoot, { pid: process.pid, run: runId });
723
+ if (self !== null) return; // boot already announced under the lock
724
+ self = announceWorker(repoRoot, { pid: process.pid, run: runId });
673
725
  stopBeating();
674
726
  const timer = setInterval(() => {
675
727
  try {
@@ -734,12 +786,16 @@ export async function createWorker(opts, deps = {}) {
734
786
  /** Withdraw this process's announcement (`worker.json`) if it is still ours. */
735
787
  function release() {
736
788
  stopBeating();
737
- retractWorker(repoRoot, process.pid);
789
+ if (self !== null) retractWorker(repoRoot, self.pid, self.start_time);
790
+ self = null;
738
791
  }
739
792
 
740
- return { processTicket, drain, run, once, stop, release, jevKeyResolved: jevKey !== null, sessionEnv: childEnv };
793
+ return { processTicket, drain, run, once, stop, release, announce, watchTick: tickWatch, jevKeyResolved: jevKey !== null, sessionEnv: childEnv };
741
794
  }
742
795
 
796
+ /** This block permits one registered worker per queue. */
797
+ const WORKER_LIMIT = 1;
798
+
743
799
  /**
744
800
  * Start-up for the `worker` verb: run record, repo root, config, run temp root (owned by this
745
801
  * process), run key; refuses when another live worker already serves this queue.
@@ -762,13 +818,22 @@ export async function bootWorker(opts, deps = {}) {
762
818
  if (!loaded.ok) throw new WorkerError('config', loaded.message ?? 'the project config cannot be loaded');
763
819
  cfg = loaded.config;
764
820
  }
765
- const other = liveWorker(repoRoot);
766
- if (other && other.pid !== process.pid) throw new WorkerError('worker-running', `worker pid ${other.pid} already serves this queue`);
767
821
  const tmpRoot = typeof cfg?.tmp?.root === 'string' ? cfg.tmp.root : undefined;
768
822
  const runRootDir = runRoot(opts.runId, { root: tmpRoot });
769
823
  setRunRoot(runRootDir);
770
824
  const key = await loadKey(opts.runId);
771
825
  const worker = await createWorker({ runId: opts.runId, repoRoot, cfg, runRootDir, slug: record.project, key, pollMs: opts.pollMs, guardRunRecordPath: runRecordPath(opts.runId) }, deps);
826
+ deps.hooks?.beforeLock?.();
827
+ await withLock(`worker-boot:${repoRoot}`, async () => {
828
+ const entries = workerRegistry(repoRoot, { prune: true });
829
+ let count = entries.filter((entry) => entry.live).length;
830
+ // Older workers have only worker.json; do not boot over a live legacy owner.
831
+ const legacy = liveWorker(repoRoot);
832
+ if (legacy && !entries.some((entry) => entry.worker?.pid === legacy.pid && entry.worker.start_time === legacy.start_time)) count++;
833
+ await deps.hooks?.afterCount?.(count);
834
+ if (count >= WORKER_LIMIT) throw new WorkerError('worker-running', 'a live or unreadable worker entry already serves this queue');
835
+ worker.announce();
836
+ });
772
837
  return { ...worker, repoRoot, runRootDir };
773
838
  }
774
839
 
@@ -811,7 +876,7 @@ export async function launchWorker({ runId, workspace, env = process.env, timeou
811
876
  child.unref();
812
877
  const deadline = Date.now() + timeoutMs;
813
878
  while (Date.now() < deadline) {
814
- if (typeof pid === 'number' && liveWorker(repoRoot)?.pid === pid) return { pid, repoRoot };
879
+ if (typeof pid === 'number' && liveWorker(repoRoot, { pid, registeredOnly: true })) return { pid, repoRoot };
815
880
  if (child.exitCode !== null || child.signalCode !== null) break;
816
881
  await new Promise((resolve) => setTimeout(resolve, 50));
817
882
  }