@ricsam/r5d-worker 0.0.90 → 0.0.92

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.
@@ -0,0 +1,135 @@
1
+ import path from "node:path";
2
+ import { spawn } from "node:child_process";
3
+ import { recoverStaleProjectWorktreeSnapshots } from "./project-worktrees.mjs";
4
+ const PROJECT_SNAPSHOT_RECOVERY_HELPER_COMMAND = "__recover-project-snapshots";
5
+ function requireAbsolutePath(value, label) {
6
+ if (!value) throw new Error(`Missing ${label}`);
7
+ const resolved = path.resolve(value);
8
+ if (resolved !== value) throw new Error(`${label} must be an absolute path`);
9
+ return resolved;
10
+ }
11
+ function parseHelperArguments(args) {
12
+ let projectsRoot;
13
+ let snapshotRoot;
14
+ for (let index = 0; index < args.length; index += 1) {
15
+ const argument = args[index];
16
+ if (argument === "--projects-root") {
17
+ projectsRoot = args[index + 1];
18
+ index += 1;
19
+ continue;
20
+ }
21
+ if (argument === "--snapshot-root") {
22
+ snapshotRoot = args[index + 1];
23
+ index += 1;
24
+ continue;
25
+ }
26
+ throw new Error(`Unknown project snapshot recovery helper argument: ${argument ?? "(missing)"}`);
27
+ }
28
+ return {
29
+ projectsRoot: requireAbsolutePath(projectsRoot, "projects root"),
30
+ snapshotRoot: requireAbsolutePath(snapshotRoot, "snapshot root")
31
+ };
32
+ }
33
+ function writeHelperMessage(message) {
34
+ process.stdout.write(`${JSON.stringify(message)}
35
+ `);
36
+ }
37
+ function runProjectSnapshotRecoveryHelper(args) {
38
+ const { projectsRoot, snapshotRoot } = parseHelperArguments(args);
39
+ const result = recoverStaleProjectWorktreeSnapshots({
40
+ projectsRoot,
41
+ temporaryRoot: snapshotRoot,
42
+ onProgress: (progress) => writeHelperMessage({ type: "progress", progress })
43
+ });
44
+ writeHelperMessage({ type: "result", result });
45
+ return result.failed.length === 0 ? 0 : 1;
46
+ }
47
+ function isRecoveryProgress(value) {
48
+ if (!value || typeof value !== "object") return false;
49
+ const progress = value;
50
+ return progress.operation === "restore" && (progress.phase === "scanning" || progress.phase === "copying" || progress.phase === "moving" || progress.phase === "syncing" || progress.phase === "cleaning") && typeof progress.projectRoot === "string" && Number.isSafeInteger(progress.completedBranches) && Number.isSafeInteger(progress.totalBranches) && typeof progress.completedBytes === "number" && Number.isFinite(progress.completedBytes) && (progress.totalBytes === void 0 || typeof progress.totalBytes === "number" && Number.isFinite(progress.totalBytes));
51
+ }
52
+ function isRecoveryResult(value) {
53
+ if (!value || typeof value !== "object") return false;
54
+ const result = value;
55
+ return Array.isArray(result.restored) && Array.isArray(result.removed) && Array.isArray(result.failed);
56
+ }
57
+ function recoveryChildEnvironment() {
58
+ const environment = {};
59
+ for (const name of ["PATH", "TMPDIR", "TMP", "TEMP", "SystemRoot", "WINDIR"]) {
60
+ const value = process.env[name];
61
+ if (value !== void 0) environment[name] = value;
62
+ }
63
+ return environment;
64
+ }
65
+ async function recoverProjectSnapshotsInChild(input) {
66
+ const entrypoint = process.argv[1];
67
+ if (!entrypoint) throw new Error("Cannot locate the r5d-worker runtime entrypoint for snapshot recovery");
68
+ const child = spawn(
69
+ process.execPath,
70
+ [
71
+ entrypoint,
72
+ PROJECT_SNAPSHOT_RECOVERY_HELPER_COMMAND,
73
+ "--projects-root",
74
+ path.resolve(input.projectsRoot),
75
+ "--snapshot-root",
76
+ path.resolve(input.snapshotRoot)
77
+ ],
78
+ {
79
+ env: recoveryChildEnvironment(),
80
+ stdio: ["ignore", "pipe", "pipe"]
81
+ }
82
+ );
83
+ const terminateOnParentExit = () => {
84
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM");
85
+ };
86
+ const parentProcessEvents = process;
87
+ parentProcessEvents.once("exit", terminateOnParentExit);
88
+ let stdout = "";
89
+ let stderr = "";
90
+ let result;
91
+ child.stdout.setEncoding("utf8");
92
+ child.stdout.on("data", (chunk) => {
93
+ stdout += chunk;
94
+ while (true) {
95
+ const newline = stdout.indexOf("\n");
96
+ if (newline < 0) break;
97
+ const line = stdout.slice(0, newline);
98
+ stdout = stdout.slice(newline + 1);
99
+ if (!line.trim()) continue;
100
+ let message;
101
+ try {
102
+ message = JSON.parse(line);
103
+ } catch {
104
+ stderr += `Invalid recovery helper output: ${line}
105
+ `;
106
+ continue;
107
+ }
108
+ if (!message || typeof message !== "object") continue;
109
+ const record = message;
110
+ if (record.type === "progress" && isRecoveryProgress(record.progress)) input.onProgress?.(record.progress);
111
+ if (record.type === "result" && isRecoveryResult(record.result)) result = record.result;
112
+ }
113
+ });
114
+ child.stderr.setEncoding("utf8");
115
+ child.stderr.on("data", (chunk) => {
116
+ stderr = `${stderr}${chunk}`.slice(-32768);
117
+ });
118
+ const exitCode = await new Promise((resolve, reject) => {
119
+ child.once("error", reject);
120
+ child.once("close", (code, signal) => {
121
+ if (signal) reject(new Error(`Project snapshot recovery helper exited on signal ${signal}`));
122
+ else resolve(code ?? 1);
123
+ });
124
+ }).finally(() => parentProcessEvents.off("exit", terminateOnParentExit));
125
+ if (exitCode !== 0 || !result) {
126
+ const detail = stderr.trim();
127
+ throw new Error(detail || `Project snapshot recovery helper exited with status ${exitCode}`);
128
+ }
129
+ return result;
130
+ }
131
+ export {
132
+ PROJECT_SNAPSHOT_RECOVERY_HELPER_COMMAND,
133
+ recoverProjectSnapshotsInChild,
134
+ runProjectSnapshotRecoveryHelper
135
+ };
@@ -265,7 +265,7 @@ function normalizeSnapshotManifest(value) {
265
265
  if (record.version !== PROJECT_WORKTREE_SNAPSHOT_MANIFEST_VERSION) {
266
266
  throw new Error("Unsupported project worktree snapshot manifest version");
267
267
  }
268
- if (record.state !== "building" && record.state !== "prepared" && record.state !== "consumed") {
268
+ if (record.state !== "building" && record.state !== "prepared" && record.state !== "restoring" && record.state !== "consumed") {
269
269
  throw new Error("Project worktree snapshot manifest has an invalid state");
270
270
  }
271
271
  if (!Number.isSafeInteger(record.ownerProcessId) || record.ownerProcessId <= 0) {
@@ -346,7 +346,7 @@ function assertSameSnapshotIdentity(expected, actual) {
346
346
  function advanceSnapshotState(snapshotRoot, expected, state) {
347
347
  const current = readSnapshotManifest(snapshotRoot);
348
348
  assertSameSnapshotIdentity(expected, current);
349
- const allowed = current.state === "building" && state === "prepared" || current.state === "prepared" && state === "consumed" || current.state === state;
349
+ const allowed = current.state === "building" && state === "prepared" || current.state === "prepared" && state === "restoring" || current.state === "prepared" && state === "consumed" || current.state === "restoring" && state === "consumed" || current.state === state;
350
350
  if (!allowed) throw new Error(`Project worktree snapshot cannot advance from ${current.state} to ${state}`);
351
351
  return current.state === state ? current : writeSnapshotManifest(snapshotRoot, { ...current, state });
352
352
  }
@@ -370,6 +370,20 @@ function copyCheckoutTree(sourceRoot, targetRoot) {
370
370
  verbatimSymlinks: true
371
371
  });
372
372
  }
373
+ function measureCheckoutTree(root) {
374
+ const stat = fs.lstatSync(root);
375
+ if (stat.isSymbolicLink()) return 0;
376
+ if (stat.isFile()) return stat.size;
377
+ if (!stat.isDirectory()) return 0;
378
+ let bytes = 0;
379
+ for (const entry of fs.readdirSync(root).sort()) bytes += measureCheckoutTree(path.join(root, entry));
380
+ return bytes;
381
+ }
382
+ function snapshotBranchMeasurements(branches, sourcePath) {
383
+ return new Map(
384
+ branches.map(({ branchName, snapshotPresent }) => [branchName, snapshotPresent ? measureCheckoutTree(sourcePath(branchName)) : 0])
385
+ );
386
+ }
373
387
  function assertNoOutstandingSnapshotForProject(temporaryRoot, projectRoot) {
374
388
  let entries;
375
389
  try {
@@ -392,7 +406,7 @@ function assertNoOutstandingSnapshotForProject(temporaryRoot, projectRoot) {
392
406
  }
393
407
  }
394
408
  }
395
- function snapshotBranchTrees(projectRoot, branches, temporaryRoot) {
409
+ function snapshotBranchTrees(projectRoot, branches, temporaryRoot, onProgress) {
396
410
  const resolvedProjectRoot = path.resolve(projectRoot);
397
411
  const resolvedTemporaryRoot = path.resolve(temporaryRoot ?? os.tmpdir());
398
412
  assertNoOutstandingSnapshotForProject(resolvedTemporaryRoot, resolvedProjectRoot);
@@ -419,13 +433,57 @@ function snapshotBranchTrees(projectRoot, branches, temporaryRoot) {
419
433
  branches: branchSnapshots,
420
434
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
421
435
  });
436
+ onProgress?.({
437
+ operation: "snapshot",
438
+ phase: "scanning",
439
+ projectRoot: resolvedProjectRoot,
440
+ completedBranches: 0,
441
+ totalBranches: branchSnapshots.length,
442
+ completedBytes: 0
443
+ });
444
+ const branchBytes = snapshotBranchMeasurements(branchSnapshots, (branchName) => branchPath(resolvedProjectRoot, branchName));
445
+ const totalBytes = [...branchBytes.values()].reduce((total, bytes) => total + bytes, 0);
446
+ let completedBytes = 0;
447
+ let completedBranches = 0;
422
448
  for (const { branchName, snapshotPresent } of branchSnapshots) {
423
- if (!snapshotPresent) continue;
424
- const snapshotPath = fullSnapshotPath(root, branchName);
425
- copyCheckoutTree(branchPath(resolvedProjectRoot, branchName), snapshotPath);
426
- paths.set(branchName, snapshotPath);
449
+ onProgress?.({
450
+ operation: "snapshot",
451
+ phase: "copying",
452
+ projectRoot: resolvedProjectRoot,
453
+ branchName,
454
+ completedBranches,
455
+ totalBranches: branchSnapshots.length,
456
+ completedBytes,
457
+ totalBytes
458
+ });
459
+ if (snapshotPresent) {
460
+ const snapshotPath = fullSnapshotPath(root, branchName);
461
+ copyCheckoutTree(branchPath(resolvedProjectRoot, branchName), snapshotPath);
462
+ paths.set(branchName, snapshotPath);
463
+ }
464
+ completedBytes += branchBytes.get(branchName) ?? 0;
465
+ completedBranches += 1;
466
+ onProgress?.({
467
+ operation: "snapshot",
468
+ phase: "copying",
469
+ projectRoot: resolvedProjectRoot,
470
+ branchName,
471
+ completedBranches,
472
+ totalBranches: branchSnapshots.length,
473
+ completedBytes,
474
+ totalBytes
475
+ });
427
476
  }
428
477
  const treesRoot = path.join(root, "trees");
478
+ onProgress?.({
479
+ operation: "snapshot",
480
+ phase: "syncing",
481
+ projectRoot: resolvedProjectRoot,
482
+ completedBranches,
483
+ totalBranches: branchSnapshots.length,
484
+ completedBytes,
485
+ totalBytes
486
+ });
429
487
  if (fs.existsSync(treesRoot)) fsyncTree(treesRoot);
430
488
  manifest = advanceSnapshotState(root, manifest, "prepared");
431
489
  return { root, manifest, paths };
@@ -434,33 +492,127 @@ function snapshotBranchTrees(projectRoot, branches, temporaryRoot) {
434
492
  throw error;
435
493
  }
436
494
  }
437
- function restoreProjectWorktreeSnapshot(snapshotRoot, manifest) {
438
- const current = readSnapshotManifest(snapshotRoot);
495
+ function restoreProjectWorktreeSnapshot(snapshotRoot, manifest, onProgress) {
496
+ let current = readSnapshotManifest(snapshotRoot);
439
497
  assertSameSnapshotIdentity(manifest, current);
440
- if (current.state !== "prepared") throw new Error(`Project worktree snapshot is ${current.state}, not prepared`);
498
+ if (current.state !== "prepared" && current.state !== "restoring") {
499
+ throw new Error(`Project worktree snapshot is ${current.state}, not prepared or restoring`);
500
+ }
501
+ const treesRoot = path.join(snapshotRoot, "trees");
502
+ const treesRootPresent = fs.existsSync(treesRoot);
503
+ const treesRootStat = treesRootPresent ? fs.lstatSync(treesRoot) : null;
504
+ if (treesRootStat && (!treesRootStat.isDirectory() || treesRootStat.isSymbolicLink())) {
505
+ throw new Error("Project worktree snapshot trees root must be a real directory");
506
+ }
507
+ const branchSourceRoot = current.state === "restoring" && !treesRootPresent ? current.projectRoot : treesRoot;
441
508
  for (const { branchName, snapshotPresent } of current.branches) {
442
509
  const targetPath = branchPath(current.projectRoot, branchName);
443
510
  assertNoSymlinkBranchPathComponents(current.projectRoot, targetPath);
444
- if (!snapshotPresent) continue;
445
- const sourcePath = fullSnapshotPath(snapshotRoot, branchName);
511
+ const sourcePath = path.join(branchSourceRoot, ...branchName.split("/"));
512
+ if (!snapshotPresent) {
513
+ if (current.state === "restoring" && !treesRootPresent && fs.existsSync(sourcePath)) {
514
+ throw new Error(`Restored project branch unexpectedly exists: ${branchName}`);
515
+ }
516
+ continue;
517
+ }
446
518
  const stat = fs.lstatSync(sourcePath);
447
519
  if (!stat.isDirectory() || stat.isSymbolicLink()) {
448
520
  throw new Error(`Project worktree snapshot branch must be a real directory: ${branchName}`);
449
521
  }
450
522
  }
523
+ onProgress?.({
524
+ operation: "restore",
525
+ phase: "scanning",
526
+ projectRoot: current.projectRoot,
527
+ completedBranches: 0,
528
+ totalBranches: current.branches.length,
529
+ completedBytes: 0
530
+ });
531
+ const branchBytes = snapshotBranchMeasurements(current.branches, (branchName) => path.join(branchSourceRoot, ...branchName.split("/")));
532
+ const totalBytes = [...branchBytes.values()].reduce((total, bytes) => total + bytes, 0);
533
+ const presentBranches = current.branches.filter(({ snapshotPresent }) => snapshotPresent);
534
+ const destinationParent = path.dirname(current.projectRoot);
535
+ fs.mkdirSync(destinationParent, { recursive: true });
536
+ const canMoveWholeSnapshot = presentBranches.length > 0 && (current.state === "restoring" || treesRootStat !== null && treesRootStat.dev === fs.lstatSync(destinationParent).dev);
537
+ if (canMoveWholeSnapshot) {
538
+ if (current.state === "prepared") current = advanceSnapshotState(snapshotRoot, current, "restoring");
539
+ onProgress?.({
540
+ operation: "restore",
541
+ phase: "moving",
542
+ projectRoot: current.projectRoot,
543
+ completedBranches: treesRootPresent ? 0 : current.branches.length,
544
+ totalBranches: current.branches.length,
545
+ completedBytes: treesRootPresent ? 0 : totalBytes,
546
+ totalBytes
547
+ });
548
+ if (treesRootPresent) {
549
+ fs.rmSync(current.projectRoot, { recursive: true, force: true });
550
+ fsyncDirectoryChain(destinationParent, path.dirname(destinationParent));
551
+ fs.renameSync(treesRoot, current.projectRoot);
552
+ fsyncDirectory(snapshotRoot);
553
+ }
554
+ fsyncManagedProjectRootEntry(current.projectRoot);
555
+ onProgress?.({
556
+ operation: "restore",
557
+ phase: "moving",
558
+ projectRoot: current.projectRoot,
559
+ completedBranches: current.branches.length,
560
+ totalBranches: current.branches.length,
561
+ completedBytes: totalBytes,
562
+ totalBytes
563
+ });
564
+ return;
565
+ }
566
+ if (current.state === "restoring") {
567
+ throw new Error("Project worktree snapshot entered atomic restore state without a movable snapshot tree");
568
+ }
451
569
  for (const { branchName } of [...current.branches].sort((left, right) => right.branchName.length - left.branchName.length)) {
452
570
  const targetPath = branchPath(current.projectRoot, branchName);
453
571
  const targetParent = path.dirname(targetPath);
454
572
  fs.rmSync(targetPath, { recursive: true, force: true });
455
573
  if (fs.existsSync(targetParent)) fsyncDirectoryChain(targetParent, current.projectRoot);
456
574
  }
575
+ let completedBytes = 0;
576
+ let completedBranches = 0;
457
577
  for (const { branchName, snapshotPresent } of current.branches) {
458
- if (!snapshotPresent) continue;
459
- const targetPath = branchPath(current.projectRoot, branchName);
460
- copyCheckoutTree(fullSnapshotPath(snapshotRoot, branchName), targetPath);
461
- fsyncTree(targetPath);
462
- fsyncDirectoryChain(path.dirname(targetPath), current.projectRoot);
578
+ onProgress?.({
579
+ operation: "restore",
580
+ phase: "copying",
581
+ projectRoot: current.projectRoot,
582
+ branchName,
583
+ completedBranches,
584
+ totalBranches: current.branches.length,
585
+ completedBytes,
586
+ totalBytes
587
+ });
588
+ if (snapshotPresent) {
589
+ const targetPath = branchPath(current.projectRoot, branchName);
590
+ copyCheckoutTree(fullSnapshotPath(snapshotRoot, branchName), targetPath);
591
+ fsyncTree(targetPath);
592
+ fsyncDirectoryChain(path.dirname(targetPath), current.projectRoot);
593
+ }
594
+ completedBytes += branchBytes.get(branchName) ?? 0;
595
+ completedBranches += 1;
596
+ onProgress?.({
597
+ operation: "restore",
598
+ phase: "copying",
599
+ projectRoot: current.projectRoot,
600
+ branchName,
601
+ completedBranches,
602
+ totalBranches: current.branches.length,
603
+ completedBytes,
604
+ totalBytes
605
+ });
463
606
  }
607
+ onProgress?.({
608
+ operation: "restore",
609
+ phase: "syncing",
610
+ projectRoot: current.projectRoot,
611
+ completedBranches,
612
+ totalBranches: current.branches.length,
613
+ completedBytes,
614
+ totalBytes
615
+ });
464
616
  fs.mkdirSync(current.projectRoot, { recursive: true });
465
617
  fsyncManagedProjectRootEntry(current.projectRoot);
466
618
  }
@@ -481,7 +633,7 @@ function persistReconciledProjectAndConsumeSnapshot(snapshot, afterProjectTreeFs
481
633
  function rollbackProjectWorktreeSnapshot(snapshot) {
482
634
  const current = readSnapshotManifest(snapshot.root);
483
635
  assertSameSnapshotIdentity(snapshot.manifest, current);
484
- if (current.state === "prepared") {
636
+ if (current.state === "prepared" || current.state === "restoring") {
485
637
  restoreProjectWorktreeSnapshot(snapshot.root, current);
486
638
  consumeAndRemoveProjectWorktreeSnapshot(snapshot.root, current);
487
639
  return;
@@ -563,8 +715,8 @@ function recoverStaleProjectWorktreeSnapshots(input) {
563
715
  assertNoSymlinkPathComponents(projectsRoot, manifest.projectRoot);
564
716
  const ownedByThisProcessSession = manifest.ownerProcessId === currentProcessId && manifest.ownerSessionId === currentOwnerSessionId;
565
717
  if (ownedByThisProcessSession || ownerProcessId !== currentProcessId && processAlive(ownerProcessId)) continue;
566
- if (manifest.state === "prepared") {
567
- restoreProjectWorktreeSnapshot(candidate, manifest);
718
+ if (manifest.state === "prepared" || manifest.state === "restoring") {
719
+ restoreProjectWorktreeSnapshot(candidate, manifest, input.onProgress);
568
720
  const consumed = advanceSnapshotState(candidate, manifest, "consumed");
569
721
  removeSnapshotStorage(candidate);
570
722
  assertSameSnapshotIdentity(manifest, consumed);
@@ -750,7 +902,11 @@ async function initializeLinkedLayout(input) {
750
902
  );
751
903
  const checkoutPath = branchPath(input.projectRoot, branch.branchName);
752
904
  fs.mkdirSync(path.dirname(checkoutPath), { recursive: true });
753
- await gitAsync(primaryPath, ["worktree", "add", "--force", checkoutPath, branch.branchName], `create linked worktree ${branch.branchName}`);
905
+ await gitAsync(
906
+ primaryPath,
907
+ ["worktree", "add", "--force", checkoutPath, branch.branchName],
908
+ `create linked worktree ${branch.branchName}`
909
+ );
754
910
  }
755
911
  return mirrorHeads;
756
912
  }
@@ -784,7 +940,11 @@ async function reconcileLinkedLayout(input) {
784
940
  fs.rmSync(checkoutPath, { recursive: true, force: true });
785
941
  await gitAsync(primaryPath, ["branch", "-f", branch.branchName, resolved.seed], `anchor project branch ${branch.branchName}`);
786
942
  fs.mkdirSync(path.dirname(checkoutPath), { recursive: true });
787
- await gitAsync(primaryPath, ["worktree", "add", "--force", checkoutPath, branch.branchName], `create linked worktree ${branch.branchName}`);
943
+ await gitAsync(
944
+ primaryPath,
945
+ ["worktree", "add", "--force", checkoutPath, branch.branchName],
946
+ `create linked worktree ${branch.branchName}`
947
+ );
788
948
  }
789
949
  return { mirrorHeads, mutatedBranches };
790
950
  }
@@ -811,7 +971,7 @@ async function ensureProjectWorktrees(input) {
811
971
  assertNoOutstandingSnapshotForProject(snapshotStorageRoot, projectRoot);
812
972
  let snapshot;
813
973
  const ensureSnapshot = () => {
814
- snapshot ??= snapshotBranchTrees(projectRoot, input.branches, snapshotStorageRoot);
974
+ snapshot ??= snapshotBranchTrees(projectRoot, input.branches, snapshotStorageRoot, input.onSnapshotProgress);
815
975
  return snapshot;
816
976
  };
817
977
  try {
@@ -5,6 +5,7 @@ const WORKER_RELOAD_EXIT_CODE = 75;
5
5
  const WORKER_RECONNECT_EXIT_CODE = 76;
6
6
  const WORKER_CREDENTIAL_RESTART_EXIT_CODE = 77;
7
7
  const WORKER_RECONNECT_DELAY_MS = 2e3;
8
+ const WORKER_RECONNECT_JITTER_MS = 3e3;
8
9
  const SUPERVISOR_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
9
10
  function defaultRuntimeSpawner(argv, options) {
10
11
  return Bun.spawn(argv, options);
@@ -12,6 +13,10 @@ function defaultRuntimeSpawner(argv, options) {
12
13
  function defaultRuntimeDelay(delayMs) {
13
14
  return new Promise((resolve) => setTimeout(resolve, delayMs));
14
15
  }
16
+ function workerReconnectDelayMs(random = Math.random) {
17
+ const sample = Math.max(0, Math.min(1, random()));
18
+ return WORKER_RECONNECT_DELAY_MS + Math.floor(sample * WORKER_RECONNECT_JITTER_MS);
19
+ }
15
20
  async function defaultRuntimeReaper(runtime) {
16
21
  await terminateProcessTree(runtime);
17
22
  }
@@ -24,7 +29,7 @@ function signalExitCode(signal) {
24
29
  function osSignalNumber(signal) {
25
30
  return osConstants.signals[signal];
26
31
  }
27
- async function superviseWorkerRuntime(argv, env = process.env, spawnRuntime = defaultRuntimeSpawner, waitBeforeReconnect = defaultRuntimeDelay, reapCredentialRuntime = defaultRuntimeReaper) {
32
+ async function superviseWorkerRuntime(argv, env = process.env, spawnRuntime = defaultRuntimeSpawner, waitBeforeReconnect = defaultRuntimeDelay, reapCredentialRuntime = defaultRuntimeReaper, reconnectDelay = workerReconnectDelayMs) {
28
33
  if (!process.argv[1]) {
29
34
  throw new Error("Cannot locate the r5d-worker entrypoint");
30
35
  }
@@ -76,9 +81,10 @@ async function superviseWorkerRuntime(argv, env = process.env, spawnRuntime = de
76
81
  continue;
77
82
  }
78
83
  if (exitCode === WORKER_RECONNECT_EXIT_CODE) {
79
- process.stderr.write(`[r5d-worker] reconnecting in ${WORKER_RECONNECT_DELAY_MS}ms
84
+ const reconnectDelayMs = reconnectDelay();
85
+ process.stderr.write(`[r5d-worker] reconnecting in ${reconnectDelayMs}ms
80
86
  `);
81
- await waitBeforeReconnect(WORKER_RECONNECT_DELAY_MS);
87
+ await waitBeforeReconnect(reconnectDelayMs);
82
88
  if (receivedSignal) return signalExitCode(receivedSignal);
83
89
  continue;
84
90
  }
@@ -93,8 +99,10 @@ export {
93
99
  WORKER_CREDENTIAL_RESTART_EXIT_CODE,
94
100
  WORKER_RECONNECT_DELAY_MS,
95
101
  WORKER_RECONNECT_EXIT_CODE,
102
+ WORKER_RECONNECT_JITTER_MS,
96
103
  WORKER_RELOAD_EXIT_CODE,
97
104
  WORKER_RUNTIME_ENV,
98
105
  isRetryableWorkerServerStatus,
99
- superviseWorkerRuntime
106
+ superviseWorkerRuntime,
107
+ workerReconnectDelayMs
100
108
  };
@@ -23,6 +23,26 @@ type WorkerProjectConfig = {
23
23
  baseCommitHash: string;
24
24
  }>;
25
25
  };
26
+ type WorkerLifecycleProgress = {
27
+ phase: "starting" | "recovering" | "configuring" | "syncing" | "ready" | "failed";
28
+ operationId: string;
29
+ detail?: string;
30
+ completedBytes?: number;
31
+ totalBytes?: number;
32
+ completedItems?: number;
33
+ totalItems?: number;
34
+ };
35
+ declare function boundedWorkerLifecycleProgress(progress: WorkerLifecycleProgress): WorkerLifecycleProgress;
36
+ declare function workerServerHeartbeatAction(input: {
37
+ elapsedMs: number;
38
+ probeElapsedMs?: number;
39
+ timeoutMs: number;
40
+ watchdogSweepDelayed: boolean;
41
+ }): "wait" | "start_probe" | "terminate";
42
+ export declare const workerLifecycleTestHarness: {
43
+ boundedWorkerLifecycleProgress: typeof boundedWorkerLifecycleProgress;
44
+ workerServerHeartbeatAction: typeof workerServerHeartbeatAction;
45
+ };
26
46
  type WorkspaceSyncTrigger = {
27
47
  type: "connect" | "inbound_head" | "write" | "edit" | "shell_inline" | "process_terminal" | "process_cancel" | "periodic" | "manual" | "remediation" | "remediation_confirm" | "remediation_reset";
28
48
  sessionId?: string;
@@ -813,12 +833,16 @@ declare function createNodePtyBridge(options: {
813
833
  }): WorkerPty;
814
834
  export declare const workerPtyBridgeTestHarness: {
815
835
  create: typeof createNodePtyBridge;
836
+ closeAllBestEffort: typeof closePtysBestEffort;
816
837
  };
817
838
  export declare function resolveHostShell(command?: string, platform?: NodeJS.Platform): {
818
839
  file: string;
819
840
  args: string[];
820
841
  env?: Record<string, string>;
821
842
  };
843
+ declare function closePtysBestEffort(ptys: Iterable<readonly [string, {
844
+ kill: () => void;
845
+ }]>, reportFailure: (ptyId: string, error: unknown) => void): void;
822
846
  declare function terminateCredentialBearingChildren(children: readonly {
823
847
  label: string;
824
848
  terminate: () => void | Promise<void>;
@@ -0,0 +1,10 @@
1
+ import { recoverStaleProjectWorktreeSnapshots, type ProjectWorktreeSnapshotProgress } from "./project-worktrees";
2
+ export declare const PROJECT_SNAPSHOT_RECOVERY_HELPER_COMMAND = "__recover-project-snapshots";
3
+ type ProjectSnapshotRecoveryResult = ReturnType<typeof recoverStaleProjectWorktreeSnapshots>;
4
+ export declare function runProjectSnapshotRecoveryHelper(args: readonly string[]): number;
5
+ export declare function recoverProjectSnapshotsInChild(input: {
6
+ projectsRoot: string;
7
+ snapshotRoot: string;
8
+ onProgress?: (progress: ProjectWorktreeSnapshotProgress) => void;
9
+ }): Promise<ProjectSnapshotRecoveryResult>;
10
+ export {};
@@ -2,7 +2,7 @@ export declare const PROJECT_WORKTREE_SNAPSHOT_PREFIX = "r5d-project-worktrees-"
2
2
  declare const PROJECT_WORKTREE_SNAPSHOT_MANIFEST_VERSION: 1;
3
3
  type ProjectWorktreeSnapshotManifest = {
4
4
  version: typeof PROJECT_WORKTREE_SNAPSHOT_MANIFEST_VERSION;
5
- state: "building" | "prepared" | "consumed";
5
+ state: "building" | "prepared" | "restoring" | "consumed";
6
6
  ownerProcessId: number;
7
7
  ownerSessionId: string;
8
8
  projectRoot: string;
@@ -12,6 +12,16 @@ type ProjectWorktreeSnapshotManifest = {
12
12
  }>;
13
13
  createdAt: string;
14
14
  };
15
+ export type ProjectWorktreeSnapshotProgress = {
16
+ operation: "snapshot" | "restore";
17
+ phase: "scanning" | "copying" | "moving" | "syncing" | "cleaning";
18
+ projectRoot: string;
19
+ branchName?: string;
20
+ completedBranches: number;
21
+ totalBranches: number;
22
+ completedBytes: number;
23
+ totalBytes?: number;
24
+ };
15
25
  export type ProjectWorktreeBranch = {
16
26
  branchId?: string;
17
27
  branchName: string;
@@ -68,6 +78,7 @@ export declare function recoverStaleProjectWorktreeSnapshots(input: {
68
78
  processAlive?: (processId: number) => boolean;
69
79
  currentProcessId?: number;
70
80
  currentOwnerSessionId?: string;
81
+ onProgress?: (progress: ProjectWorktreeSnapshotProgress) => void;
71
82
  }): {
72
83
  restored: string[];
73
84
  removed: string[];
@@ -110,6 +121,7 @@ export declare function ensureProjectWorktrees(input: {
110
121
  } | null;
111
122
  exactMirrorBranches?: ReadonlySet<string>;
112
123
  snapshotRoot?: string;
124
+ onSnapshotProgress?: (progress: ProjectWorktreeSnapshotProgress) => void;
113
125
  }): Promise<ProjectWorktreeState[]>;
114
126
  export declare function projectWorktreeOperationInProgress(checkoutPath: string): boolean;
115
127
  export declare function createLinkedProjectBranch(input: {
@@ -4,6 +4,7 @@ export declare const WORKER_RELOAD_EXIT_CODE = 75;
4
4
  export declare const WORKER_RECONNECT_EXIT_CODE = 76;
5
5
  export declare const WORKER_CREDENTIAL_RESTART_EXIT_CODE = 77;
6
6
  export declare const WORKER_RECONNECT_DELAY_MS = 2000;
7
+ export declare const WORKER_RECONNECT_JITTER_MS = 3000;
7
8
  type RuntimeProcess = KillableSubprocess;
8
9
  export type RuntimeSpawner = (argv: string[], options: {
9
10
  stdin: "inherit";
@@ -14,6 +15,8 @@ export type RuntimeSpawner = (argv: string[], options: {
14
15
  }) => RuntimeProcess;
15
16
  export type RuntimeDelay = (delayMs: number) => Promise<void>;
16
17
  export type RuntimeReaper = (runtime: RuntimeProcess) => Promise<void>;
18
+ export type RuntimeReconnectDelay = () => number;
19
+ export declare function workerReconnectDelayMs(random?: () => number): number;
17
20
  export declare function isRetryableWorkerServerStatus(status: number): boolean;
18
- export declare function superviseWorkerRuntime(argv: string[], env?: Record<string, string | undefined>, spawnRuntime?: RuntimeSpawner, waitBeforeReconnect?: RuntimeDelay, reapCredentialRuntime?: RuntimeReaper): Promise<number>;
21
+ export declare function superviseWorkerRuntime(argv: string[], env?: Record<string, string | undefined>, spawnRuntime?: RuntimeSpawner, waitBeforeReconnect?: RuntimeDelay, reapCredentialRuntime?: RuntimeReaper, reconnectDelay?: RuntimeReconnectDelay): Promise<number>;
19
22
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.90",
3
+ "version": "0.0.92",
4
4
  "type": "module",
5
5
  "main": "./dist/cjs/main.cjs",
6
6
  "module": "./dist/mjs/main.mjs",