@ricsam/r5d-worker 0.0.90 → 0.0.91

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.
@@ -318,7 +318,7 @@ function normalizeSnapshotManifest(value) {
318
318
  if (record.version !== PROJECT_WORKTREE_SNAPSHOT_MANIFEST_VERSION) {
319
319
  throw new Error("Unsupported project worktree snapshot manifest version");
320
320
  }
321
- if (record.state !== "building" && record.state !== "prepared" && record.state !== "consumed") {
321
+ if (record.state !== "building" && record.state !== "prepared" && record.state !== "restoring" && record.state !== "consumed") {
322
322
  throw new Error("Project worktree snapshot manifest has an invalid state");
323
323
  }
324
324
  if (!Number.isSafeInteger(record.ownerProcessId) || record.ownerProcessId <= 0) {
@@ -399,7 +399,7 @@ function assertSameSnapshotIdentity(expected, actual) {
399
399
  function advanceSnapshotState(snapshotRoot, expected, state) {
400
400
  const current = readSnapshotManifest(snapshotRoot);
401
401
  assertSameSnapshotIdentity(expected, current);
402
- const allowed = current.state === "building" && state === "prepared" || current.state === "prepared" && state === "consumed" || current.state === state;
402
+ 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;
403
403
  if (!allowed) throw new Error(`Project worktree snapshot cannot advance from ${current.state} to ${state}`);
404
404
  return current.state === state ? current : writeSnapshotManifest(snapshotRoot, { ...current, state });
405
405
  }
@@ -423,6 +423,20 @@ function copyCheckoutTree(sourceRoot, targetRoot) {
423
423
  verbatimSymlinks: true
424
424
  });
425
425
  }
426
+ function measureCheckoutTree(root) {
427
+ const stat = import_node_fs.default.lstatSync(root);
428
+ if (stat.isSymbolicLink()) return 0;
429
+ if (stat.isFile()) return stat.size;
430
+ if (!stat.isDirectory()) return 0;
431
+ let bytes = 0;
432
+ for (const entry of import_node_fs.default.readdirSync(root).sort()) bytes += measureCheckoutTree(import_node_path.default.join(root, entry));
433
+ return bytes;
434
+ }
435
+ function snapshotBranchMeasurements(branches, sourcePath) {
436
+ return new Map(
437
+ branches.map(({ branchName, snapshotPresent }) => [branchName, snapshotPresent ? measureCheckoutTree(sourcePath(branchName)) : 0])
438
+ );
439
+ }
426
440
  function assertNoOutstandingSnapshotForProject(temporaryRoot, projectRoot) {
427
441
  let entries;
428
442
  try {
@@ -445,7 +459,7 @@ function assertNoOutstandingSnapshotForProject(temporaryRoot, projectRoot) {
445
459
  }
446
460
  }
447
461
  }
448
- function snapshotBranchTrees(projectRoot, branches, temporaryRoot) {
462
+ function snapshotBranchTrees(projectRoot, branches, temporaryRoot, onProgress) {
449
463
  const resolvedProjectRoot = import_node_path.default.resolve(projectRoot);
450
464
  const resolvedTemporaryRoot = import_node_path.default.resolve(temporaryRoot ?? import_node_os.default.tmpdir());
451
465
  assertNoOutstandingSnapshotForProject(resolvedTemporaryRoot, resolvedProjectRoot);
@@ -472,13 +486,57 @@ function snapshotBranchTrees(projectRoot, branches, temporaryRoot) {
472
486
  branches: branchSnapshots,
473
487
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
474
488
  });
489
+ onProgress?.({
490
+ operation: "snapshot",
491
+ phase: "scanning",
492
+ projectRoot: resolvedProjectRoot,
493
+ completedBranches: 0,
494
+ totalBranches: branchSnapshots.length,
495
+ completedBytes: 0
496
+ });
497
+ const branchBytes = snapshotBranchMeasurements(branchSnapshots, (branchName) => branchPath(resolvedProjectRoot, branchName));
498
+ const totalBytes = [...branchBytes.values()].reduce((total, bytes) => total + bytes, 0);
499
+ let completedBytes = 0;
500
+ let completedBranches = 0;
475
501
  for (const { branchName, snapshotPresent } of branchSnapshots) {
476
- if (!snapshotPresent) continue;
477
- const snapshotPath = fullSnapshotPath(root, branchName);
478
- copyCheckoutTree(branchPath(resolvedProjectRoot, branchName), snapshotPath);
479
- paths.set(branchName, snapshotPath);
502
+ onProgress?.({
503
+ operation: "snapshot",
504
+ phase: "copying",
505
+ projectRoot: resolvedProjectRoot,
506
+ branchName,
507
+ completedBranches,
508
+ totalBranches: branchSnapshots.length,
509
+ completedBytes,
510
+ totalBytes
511
+ });
512
+ if (snapshotPresent) {
513
+ const snapshotPath = fullSnapshotPath(root, branchName);
514
+ copyCheckoutTree(branchPath(resolvedProjectRoot, branchName), snapshotPath);
515
+ paths.set(branchName, snapshotPath);
516
+ }
517
+ completedBytes += branchBytes.get(branchName) ?? 0;
518
+ completedBranches += 1;
519
+ onProgress?.({
520
+ operation: "snapshot",
521
+ phase: "copying",
522
+ projectRoot: resolvedProjectRoot,
523
+ branchName,
524
+ completedBranches,
525
+ totalBranches: branchSnapshots.length,
526
+ completedBytes,
527
+ totalBytes
528
+ });
480
529
  }
481
530
  const treesRoot = import_node_path.default.join(root, "trees");
531
+ onProgress?.({
532
+ operation: "snapshot",
533
+ phase: "syncing",
534
+ projectRoot: resolvedProjectRoot,
535
+ completedBranches,
536
+ totalBranches: branchSnapshots.length,
537
+ completedBytes,
538
+ totalBytes
539
+ });
482
540
  if (import_node_fs.default.existsSync(treesRoot)) fsyncTree(treesRoot);
483
541
  manifest = advanceSnapshotState(root, manifest, "prepared");
484
542
  return { root, manifest, paths };
@@ -487,33 +545,127 @@ function snapshotBranchTrees(projectRoot, branches, temporaryRoot) {
487
545
  throw error;
488
546
  }
489
547
  }
490
- function restoreProjectWorktreeSnapshot(snapshotRoot, manifest) {
491
- const current = readSnapshotManifest(snapshotRoot);
548
+ function restoreProjectWorktreeSnapshot(snapshotRoot, manifest, onProgress) {
549
+ let current = readSnapshotManifest(snapshotRoot);
492
550
  assertSameSnapshotIdentity(manifest, current);
493
- if (current.state !== "prepared") throw new Error(`Project worktree snapshot is ${current.state}, not prepared`);
551
+ if (current.state !== "prepared" && current.state !== "restoring") {
552
+ throw new Error(`Project worktree snapshot is ${current.state}, not prepared or restoring`);
553
+ }
554
+ const treesRoot = import_node_path.default.join(snapshotRoot, "trees");
555
+ const treesRootPresent = import_node_fs.default.existsSync(treesRoot);
556
+ const treesRootStat = treesRootPresent ? import_node_fs.default.lstatSync(treesRoot) : null;
557
+ if (treesRootStat && (!treesRootStat.isDirectory() || treesRootStat.isSymbolicLink())) {
558
+ throw new Error("Project worktree snapshot trees root must be a real directory");
559
+ }
560
+ const branchSourceRoot = current.state === "restoring" && !treesRootPresent ? current.projectRoot : treesRoot;
494
561
  for (const { branchName, snapshotPresent } of current.branches) {
495
562
  const targetPath = branchPath(current.projectRoot, branchName);
496
563
  assertNoSymlinkBranchPathComponents(current.projectRoot, targetPath);
497
- if (!snapshotPresent) continue;
498
- const sourcePath = fullSnapshotPath(snapshotRoot, branchName);
564
+ const sourcePath = import_node_path.default.join(branchSourceRoot, ...branchName.split("/"));
565
+ if (!snapshotPresent) {
566
+ if (current.state === "restoring" && !treesRootPresent && import_node_fs.default.existsSync(sourcePath)) {
567
+ throw new Error(`Restored project branch unexpectedly exists: ${branchName}`);
568
+ }
569
+ continue;
570
+ }
499
571
  const stat = import_node_fs.default.lstatSync(sourcePath);
500
572
  if (!stat.isDirectory() || stat.isSymbolicLink()) {
501
573
  throw new Error(`Project worktree snapshot branch must be a real directory: ${branchName}`);
502
574
  }
503
575
  }
576
+ onProgress?.({
577
+ operation: "restore",
578
+ phase: "scanning",
579
+ projectRoot: current.projectRoot,
580
+ completedBranches: 0,
581
+ totalBranches: current.branches.length,
582
+ completedBytes: 0
583
+ });
584
+ const branchBytes = snapshotBranchMeasurements(current.branches, (branchName) => import_node_path.default.join(branchSourceRoot, ...branchName.split("/")));
585
+ const totalBytes = [...branchBytes.values()].reduce((total, bytes) => total + bytes, 0);
586
+ const presentBranches = current.branches.filter(({ snapshotPresent }) => snapshotPresent);
587
+ const destinationParent = import_node_path.default.dirname(current.projectRoot);
588
+ import_node_fs.default.mkdirSync(destinationParent, { recursive: true });
589
+ const canMoveWholeSnapshot = presentBranches.length > 0 && (current.state === "restoring" || treesRootStat !== null && treesRootStat.dev === import_node_fs.default.lstatSync(destinationParent).dev);
590
+ if (canMoveWholeSnapshot) {
591
+ if (current.state === "prepared") current = advanceSnapshotState(snapshotRoot, current, "restoring");
592
+ onProgress?.({
593
+ operation: "restore",
594
+ phase: "moving",
595
+ projectRoot: current.projectRoot,
596
+ completedBranches: treesRootPresent ? 0 : current.branches.length,
597
+ totalBranches: current.branches.length,
598
+ completedBytes: treesRootPresent ? 0 : totalBytes,
599
+ totalBytes
600
+ });
601
+ if (treesRootPresent) {
602
+ import_node_fs.default.rmSync(current.projectRoot, { recursive: true, force: true });
603
+ fsyncDirectoryChain(destinationParent, import_node_path.default.dirname(destinationParent));
604
+ import_node_fs.default.renameSync(treesRoot, current.projectRoot);
605
+ fsyncDirectory(snapshotRoot);
606
+ }
607
+ fsyncManagedProjectRootEntry(current.projectRoot);
608
+ onProgress?.({
609
+ operation: "restore",
610
+ phase: "moving",
611
+ projectRoot: current.projectRoot,
612
+ completedBranches: current.branches.length,
613
+ totalBranches: current.branches.length,
614
+ completedBytes: totalBytes,
615
+ totalBytes
616
+ });
617
+ return;
618
+ }
619
+ if (current.state === "restoring") {
620
+ throw new Error("Project worktree snapshot entered atomic restore state without a movable snapshot tree");
621
+ }
504
622
  for (const { branchName } of [...current.branches].sort((left, right) => right.branchName.length - left.branchName.length)) {
505
623
  const targetPath = branchPath(current.projectRoot, branchName);
506
624
  const targetParent = import_node_path.default.dirname(targetPath);
507
625
  import_node_fs.default.rmSync(targetPath, { recursive: true, force: true });
508
626
  if (import_node_fs.default.existsSync(targetParent)) fsyncDirectoryChain(targetParent, current.projectRoot);
509
627
  }
628
+ let completedBytes = 0;
629
+ let completedBranches = 0;
510
630
  for (const { branchName, snapshotPresent } of current.branches) {
511
- if (!snapshotPresent) continue;
512
- const targetPath = branchPath(current.projectRoot, branchName);
513
- copyCheckoutTree(fullSnapshotPath(snapshotRoot, branchName), targetPath);
514
- fsyncTree(targetPath);
515
- fsyncDirectoryChain(import_node_path.default.dirname(targetPath), current.projectRoot);
631
+ onProgress?.({
632
+ operation: "restore",
633
+ phase: "copying",
634
+ projectRoot: current.projectRoot,
635
+ branchName,
636
+ completedBranches,
637
+ totalBranches: current.branches.length,
638
+ completedBytes,
639
+ totalBytes
640
+ });
641
+ if (snapshotPresent) {
642
+ const targetPath = branchPath(current.projectRoot, branchName);
643
+ copyCheckoutTree(fullSnapshotPath(snapshotRoot, branchName), targetPath);
644
+ fsyncTree(targetPath);
645
+ fsyncDirectoryChain(import_node_path.default.dirname(targetPath), current.projectRoot);
646
+ }
647
+ completedBytes += branchBytes.get(branchName) ?? 0;
648
+ completedBranches += 1;
649
+ onProgress?.({
650
+ operation: "restore",
651
+ phase: "copying",
652
+ projectRoot: current.projectRoot,
653
+ branchName,
654
+ completedBranches,
655
+ totalBranches: current.branches.length,
656
+ completedBytes,
657
+ totalBytes
658
+ });
516
659
  }
660
+ onProgress?.({
661
+ operation: "restore",
662
+ phase: "syncing",
663
+ projectRoot: current.projectRoot,
664
+ completedBranches,
665
+ totalBranches: current.branches.length,
666
+ completedBytes,
667
+ totalBytes
668
+ });
517
669
  import_node_fs.default.mkdirSync(current.projectRoot, { recursive: true });
518
670
  fsyncManagedProjectRootEntry(current.projectRoot);
519
671
  }
@@ -534,7 +686,7 @@ function persistReconciledProjectAndConsumeSnapshot(snapshot, afterProjectTreeFs
534
686
  function rollbackProjectWorktreeSnapshot(snapshot) {
535
687
  const current = readSnapshotManifest(snapshot.root);
536
688
  assertSameSnapshotIdentity(snapshot.manifest, current);
537
- if (current.state === "prepared") {
689
+ if (current.state === "prepared" || current.state === "restoring") {
538
690
  restoreProjectWorktreeSnapshot(snapshot.root, current);
539
691
  consumeAndRemoveProjectWorktreeSnapshot(snapshot.root, current);
540
692
  return;
@@ -616,8 +768,8 @@ function recoverStaleProjectWorktreeSnapshots(input) {
616
768
  assertNoSymlinkPathComponents(projectsRoot, manifest.projectRoot);
617
769
  const ownedByThisProcessSession = manifest.ownerProcessId === currentProcessId && manifest.ownerSessionId === currentOwnerSessionId;
618
770
  if (ownedByThisProcessSession || ownerProcessId !== currentProcessId && processAlive(ownerProcessId)) continue;
619
- if (manifest.state === "prepared") {
620
- restoreProjectWorktreeSnapshot(candidate, manifest);
771
+ if (manifest.state === "prepared" || manifest.state === "restoring") {
772
+ restoreProjectWorktreeSnapshot(candidate, manifest, input.onProgress);
621
773
  const consumed = advanceSnapshotState(candidate, manifest, "consumed");
622
774
  removeSnapshotStorage(candidate);
623
775
  assertSameSnapshotIdentity(manifest, consumed);
@@ -803,7 +955,11 @@ async function initializeLinkedLayout(input) {
803
955
  );
804
956
  const checkoutPath = branchPath(input.projectRoot, branch.branchName);
805
957
  import_node_fs.default.mkdirSync(import_node_path.default.dirname(checkoutPath), { recursive: true });
806
- await gitAsync(primaryPath, ["worktree", "add", "--force", checkoutPath, branch.branchName], `create linked worktree ${branch.branchName}`);
958
+ await gitAsync(
959
+ primaryPath,
960
+ ["worktree", "add", "--force", checkoutPath, branch.branchName],
961
+ `create linked worktree ${branch.branchName}`
962
+ );
807
963
  }
808
964
  return mirrorHeads;
809
965
  }
@@ -837,7 +993,11 @@ async function reconcileLinkedLayout(input) {
837
993
  import_node_fs.default.rmSync(checkoutPath, { recursive: true, force: true });
838
994
  await gitAsync(primaryPath, ["branch", "-f", branch.branchName, resolved.seed], `anchor project branch ${branch.branchName}`);
839
995
  import_node_fs.default.mkdirSync(import_node_path.default.dirname(checkoutPath), { recursive: true });
840
- await gitAsync(primaryPath, ["worktree", "add", "--force", checkoutPath, branch.branchName], `create linked worktree ${branch.branchName}`);
996
+ await gitAsync(
997
+ primaryPath,
998
+ ["worktree", "add", "--force", checkoutPath, branch.branchName],
999
+ `create linked worktree ${branch.branchName}`
1000
+ );
841
1001
  }
842
1002
  return { mirrorHeads, mutatedBranches };
843
1003
  }
@@ -864,7 +1024,7 @@ async function ensureProjectWorktrees(input) {
864
1024
  assertNoOutstandingSnapshotForProject(snapshotStorageRoot, projectRoot);
865
1025
  let snapshot;
866
1026
  const ensureSnapshot = () => {
867
- snapshot ??= snapshotBranchTrees(projectRoot, input.branches, snapshotStorageRoot);
1027
+ snapshot ??= snapshotBranchTrees(projectRoot, input.branches, snapshotStorageRoot, input.onSnapshotProgress);
868
1028
  return snapshot;
869
1029
  };
870
1030
  try {
package/dist/mjs/main.mjs CHANGED
@@ -58,7 +58,6 @@ import { assertDisjointManagedRoots, managedProjectRoot, validateManagedBranchNa
58
58
  import {
59
59
  applyObservedProjectMirrorHeads,
60
60
  assertProjectMirrorHeadsPushed,
61
- recoverStaleProjectWorktreeSnapshots,
62
61
  createOrRetryLinkedProjectBranch,
63
62
  deleteLinkedProjectBranch,
64
63
  deleteProjectMirrorBranch,
@@ -71,6 +70,11 @@ import {
71
70
  pushProjectMirrorHeads,
72
71
  removeProjectWorktrees
73
72
  } from "./project-worktrees.mjs";
73
+ import {
74
+ PROJECT_SNAPSHOT_RECOVERY_HELPER_COMMAND,
75
+ recoverProjectSnapshotsInChild,
76
+ runProjectSnapshotRecoveryHelper
77
+ } from "./project-snapshot-recovery-runner.mjs";
74
78
  import {
75
79
  ProjectWorkspacePendingBranchPathChangeError,
76
80
  ProjectWorkspaceStateStore,
@@ -3411,28 +3415,6 @@ async function startWorker(options) {
3411
3415
  runGit(["--version"]);
3412
3416
  await verifyR5dctlAuth(baseUrl, token, label);
3413
3417
  const initializedWorkspaceState = await workspaceSyncSingleFlight.runExclusive(() => {
3414
- if (!startupProjectSnapshotRecoveryCompleted) {
3415
- const snapshotRecovery = recoverStaleProjectWorktreeSnapshots({
3416
- projectsRoot,
3417
- temporaryRoot: projectWorktreeSnapshotsRoot
3418
- });
3419
- if (snapshotRecovery.restored.length > 0) {
3420
- process.stdout.write(`[r5d-worker] restored ${snapshotRecovery.restored.length} interrupted project snapshot(s)
3421
- `);
3422
- }
3423
- if (snapshotRecovery.removed.length > 0) {
3424
- process.stdout.write(`[r5d-worker] removed ${snapshotRecovery.removed.length} completed/incomplete project snapshot(s)
3425
- `);
3426
- }
3427
- for (const failure of snapshotRecovery.failed) {
3428
- process.stderr.write(`[r5d-worker] failed to recover stale project snapshot ${failure.path}: ${failure.error}
3429
- `);
3430
- }
3431
- if (snapshotRecovery.failed.length > 0) {
3432
- throw new Error("Project snapshot recovery is incomplete; refusing to mutate project checkouts");
3433
- }
3434
- startupProjectSnapshotRecoveryCompleted = true;
3435
- }
3436
3418
  fs.mkdirSync(projectsRoot, { recursive: true });
3437
3419
  fs.mkdirSync(syncRoot, { recursive: true });
3438
3420
  fs.mkdirSync(artifactRoot, { recursive: true });
@@ -3471,6 +3453,8 @@ async function startWorker(options) {
3471
3453
  let cliUpdateInProgress = false;
3472
3454
  let reloadAfterClose = false;
3473
3455
  let shutdownAfterClose = false;
3456
+ let startupRecoveryComplete = startupProjectSnapshotRecoveryCompleted;
3457
+ let startupRecoveryPromise = Promise.resolve();
3474
3458
  const automaticWorkspaceSyncIsCurrentlyAdmitted = () => workspaceAutomaticSyncIsAdmitted({
3475
3459
  workspaceConfigured,
3476
3460
  activeIncidentId: activeWorkspaceIncidentId,
@@ -3887,8 +3871,14 @@ async function startWorker(options) {
3887
3871
  })
3888
3872
  )
3889
3873
  ].sort();
3890
- const ensureConfiguredProjects = async () => {
3891
- for (const project of projectConfigById.values()) {
3874
+ const ensureConfiguredProjects = async (onProgress) => {
3875
+ const projects = [...projectConfigById.values()];
3876
+ for (const [projectIndex, project] of projects.entries()) {
3877
+ onProgress?.({
3878
+ detail: `Configuring ${project.projectPath}`,
3879
+ completedItems: projectIndex,
3880
+ totalItems: projects.length
3881
+ });
3892
3882
  validateProjectId(project.projectId);
3893
3883
  assertRepositoryTransitionState(project);
3894
3884
  for (const branch of project.branches) validateBranchName(branch.branchName);
@@ -3936,7 +3926,14 @@ async function startWorker(options) {
3936
3926
  mirrorUrl: connection.mirrorUrl,
3937
3927
  mirrorCredentialUsername: connection.mirrorCredentialUsername,
3938
3928
  credentialHelper: connection.credentialHelper,
3939
- gitIdentity: workspaceGitIdentity
3929
+ gitIdentity: workspaceGitIdentity,
3930
+ onSnapshotProgress: (progress) => onProgress?.({
3931
+ detail: progress.phase === "scanning" ? `Measuring ${project.projectPath} workspace snapshot` : progress.phase === "syncing" ? `Making ${project.projectPath} workspace snapshot durable` : `${progress.operation === "restore" ? "Restoring" : "Snapshotting"} ${project.projectPath}${progress.branchName ? `/${progress.branchName}` : ""}`,
3932
+ completedBytes: progress.completedBytes,
3933
+ completedItems: progress.completedBranches,
3934
+ totalItems: progress.totalBranches,
3935
+ ...progress.totalBytes === void 0 ? {} : { totalBytes: progress.totalBytes }
3936
+ })
3940
3937
  });
3941
3938
  readyProjectIds.add(project.projectId);
3942
3939
  reconciledProjectConfigFingerprints.set(project.projectId, configFingerprint);
@@ -3971,6 +3968,11 @@ async function startWorker(options) {
3971
3968
  `
3972
3969
  );
3973
3970
  }
3971
+ onProgress?.({
3972
+ detail: `Configured ${project.projectPath}`,
3973
+ completedItems: projectIndex + 1,
3974
+ totalItems: projects.length
3975
+ });
3974
3976
  }
3975
3977
  };
3976
3978
  const collectAheadOfOriginBranches = async () => {
@@ -4363,6 +4365,11 @@ async function startWorker(options) {
4363
4365
  if (input.sendResult !== false) sendWorkspaceSyncResult(input.requestId, result2);
4364
4366
  return result2;
4365
4367
  }
4368
+ sendLifecycleProgress({
4369
+ phase: "syncing",
4370
+ operationId: attemptId,
4371
+ detail: input.trigger.detail ?? `Synchronizing workspace (${input.trigger.type})`
4372
+ });
4366
4373
  if (!input.requestId && input.sendResult !== false) {
4367
4374
  if (currentWorkerSocket !== ws || ws.readyState !== WebSocket.OPEN) {
4368
4375
  throw new Error("Cannot start autonomous workspace synchronization without an open worker control socket");
@@ -4465,6 +4472,9 @@ async function startWorker(options) {
4465
4472
  }
4466
4473
  };
4467
4474
  if (input.sendResult !== false) sendWorkspaceSyncResult(input.requestId, result);
4475
+ sendLifecycleProgress(
4476
+ result.outcome === "failed" ? { phase: "failed", operationId: attemptId, detail: result.error ?? "Workspace synchronization failed" } : { phase: "ready", operationId: attemptId, detail: "Workspace synchronized" }
4477
+ );
4468
4478
  return result;
4469
4479
  };
4470
4480
  const scheduleAutomaticWorkspaceSync = (trigger, delayMs = WORKSPACE_GIT_QUIET_MS) => {
@@ -4539,6 +4549,14 @@ async function startWorker(options) {
4539
4549
  });
4540
4550
  };
4541
4551
  const configureWorkerWorkspace = async (message, receiptGeneration) => {
4552
+ const configurationOperationId = crypto.randomUUID();
4553
+ sendLifecycleProgress({
4554
+ phase: "configuring",
4555
+ operationId: configurationOperationId,
4556
+ detail: `Preparing ${message.projects.length} project(s)`,
4557
+ completedItems: 0,
4558
+ totalItems: message.projects.length
4559
+ });
4542
4560
  const incidentDeferral = workspaceConfigurationIncidentDeferral({
4543
4561
  requestedIncidentId: message.deferWorkspaceSyncForIncidentId,
4544
4562
  activeIncidentId: activeWorkspaceIncidentId
@@ -4903,7 +4921,13 @@ async function startWorker(options) {
4903
4921
  // checkout initialization from erasing synchronized dirt.
4904
4922
  { ignoreBusy: true }
4905
4923
  );
4906
- await ensureConfiguredProjects();
4924
+ await ensureConfiguredProjects((progress) => {
4925
+ sendLifecycleProgress({ phase: "configuring", operationId: configurationOperationId, ...progress });
4926
+ process.stdout.write(
4927
+ `[r5d-worker] ${progress.detail ?? "Configuring workspace"}${progress.completedBytes === void 0 ? "" : `: ${progress.completedBytes}/${progress.totalBytes ?? "?"} bytes`}${progress.completedItems === void 0 ? "" : `, ${progress.completedItems}/${progress.totalItems ?? "?"} item(s)`}
4928
+ `
4929
+ );
4930
+ });
4907
4931
  const configurationSyncAdmissionGeneration = workerAdmissionGeneration;
4908
4932
  const assertConfigurationSyncStillAdmitted = () => {
4909
4933
  if (receiptGeneration !== workspaceConfigurationReceiptGeneration || configurationSyncAdmissionGeneration !== workerAdmissionGeneration || !workspaceConfigurationIncidentSnapshotIsCurrent(incidentDeferral.incidentId, activeWorkspaceIncidentId) || currentWorkerSocket !== ws || ws.readyState !== WebSocket.OPEN) {
@@ -5002,6 +5026,60 @@ async function startWorker(options) {
5002
5026
  Authorization: `Bearer ${token}`
5003
5027
  }
5004
5028
  });
5029
+ const sendLifecycleProgress = (progress) => {
5030
+ if (currentWorkerSocket !== ws || ws.readyState !== WebSocket.OPEN) return;
5031
+ sendWorkerMessage(ws, { type: "worker_progress", progress });
5032
+ };
5033
+ const describeSnapshotProgress = (progress) => {
5034
+ const project = path.relative(projectsRoot, progress.projectRoot) || path.basename(progress.projectRoot);
5035
+ const branch = progress.branchName ? `/${progress.branchName}` : "";
5036
+ if (progress.phase === "scanning") return `Scanning ${project} recovery snapshot`;
5037
+ if (progress.phase === "syncing") return `Making restored ${project} files durable`;
5038
+ if (progress.phase === "cleaning") return `Cleaning recovered ${project} snapshot`;
5039
+ return `Restoring ${project}${branch}`;
5040
+ };
5041
+ const performStartupRecovery = async (operationId) => {
5042
+ if (startupProjectSnapshotRecoveryCompleted) return;
5043
+ sendLifecycleProgress({ phase: "recovering", operationId, detail: "Checking interrupted workspace transactions" });
5044
+ const snapshotRecovery = await workspaceSyncSingleFlight.runExclusive(
5045
+ async () => await recoverProjectSnapshotsInChild({
5046
+ projectsRoot,
5047
+ snapshotRoot: projectWorktreeSnapshotsRoot,
5048
+ onProgress: (progress) => {
5049
+ const lifecycleProgress = {
5050
+ phase: "recovering",
5051
+ operationId,
5052
+ detail: describeSnapshotProgress(progress),
5053
+ completedBytes: progress.completedBytes,
5054
+ completedItems: progress.completedBranches,
5055
+ totalItems: progress.totalBranches,
5056
+ ...progress.totalBytes === void 0 ? {} : { totalBytes: progress.totalBytes }
5057
+ };
5058
+ sendLifecycleProgress(lifecycleProgress);
5059
+ process.stdout.write(
5060
+ `[r5d-worker] ${lifecycleProgress.detail}: ${progress.completedBranches}/${progress.totalBranches} branch(es), ${progress.completedBytes}/${progress.totalBytes ?? "?"} bytes
5061
+ `
5062
+ );
5063
+ }
5064
+ })
5065
+ );
5066
+ if (snapshotRecovery.restored.length > 0) {
5067
+ process.stdout.write(`[r5d-worker] restored ${snapshotRecovery.restored.length} interrupted project snapshot(s)
5068
+ `);
5069
+ }
5070
+ if (snapshotRecovery.removed.length > 0) {
5071
+ process.stdout.write(`[r5d-worker] removed ${snapshotRecovery.removed.length} completed/incomplete project snapshot(s)
5072
+ `);
5073
+ }
5074
+ for (const failure of snapshotRecovery.failed) {
5075
+ process.stderr.write(`[r5d-worker] failed to recover stale project snapshot ${failure.path}: ${failure.error}
5076
+ `);
5077
+ }
5078
+ if (snapshotRecovery.failed.length > 0) {
5079
+ throw new Error("Project snapshot recovery is incomplete; refusing to mutate project checkouts");
5080
+ }
5081
+ startupProjectSnapshotRecoveryCompleted = true;
5082
+ };
5005
5083
  const handleGracefulShutdown = () => {
5006
5084
  if (shutdownAfterClose) return;
5007
5085
  shutdownAfterClose = true;
@@ -5031,8 +5109,27 @@ async function startWorker(options) {
5031
5109
  process.on("SIGINT", handleGracefulShutdown);
5032
5110
  ws.addEventListener("open", () => {
5033
5111
  currentWorkerSocket = ws;
5112
+ const startupOperationId = crypto.randomUUID();
5113
+ startupRecoveryPromise = Promise.resolve().then(async () => await performStartupRecovery(startupOperationId)).then(() => {
5114
+ startupRecoveryComplete = true;
5115
+ if (currentWorkerSocket === ws && ws.readyState === WebSocket.OPEN) {
5116
+ sendWorkerMessage(ws, { type: "startup_ready" });
5117
+ sendLifecycleProgress({
5118
+ phase: "configuring",
5119
+ operationId: crypto.randomUUID(),
5120
+ detail: "Waiting for workspace configuration"
5121
+ });
5122
+ }
5123
+ }).catch((error) => {
5124
+ const message = error instanceof Error ? error.message : String(error);
5125
+ sendLifecycleProgress({ phase: "failed", operationId: startupOperationId, detail: message });
5126
+ process.stderr.write(`[r5d-worker] startup recovery failed: ${message}
5127
+ `);
5128
+ ws.close(1011, "Project snapshot recovery failed");
5129
+ });
5034
5130
  const hello = {
5035
5131
  type: "hello",
5132
+ startupReady: startupRecoveryComplete,
5036
5133
  hostInfo: {
5037
5134
  hostname: hostname(),
5038
5135
  platform: process.platform,
@@ -5070,6 +5167,8 @@ async function startWorker(options) {
5070
5167
  ws.addEventListener("message", (event) => {
5071
5168
  void (async () => {
5072
5169
  const message = JSON.parse(String(event.data));
5170
+ if (!startupRecoveryComplete && message.type !== "ping") await startupRecoveryPromise;
5171
+ if (!startupRecoveryComplete && message.type !== "ping") return;
5073
5172
  const messageAdmissionGeneration = workerAdmissionGeneration;
5074
5173
  const assertMessageAdmission = () => assertWorkerChildAdmission({
5075
5174
  capturedGeneration: messageAdmissionGeneration,
@@ -5097,6 +5196,19 @@ async function startWorker(options) {
5097
5196
  aheadOfOriginBranches: configured.aheadOfOriginBranches,
5098
5197
  ...configured.deferredWorkspaceSyncForIncidentId ? { deferredWorkspaceSyncForIncidentId: configured.deferredWorkspaceSyncForIncidentId } : {}
5099
5198
  });
5199
+ sendLifecycleProgress(
5200
+ configured.result.outcome === "failed" ? {
5201
+ phase: "failed",
5202
+ operationId: configured.result.attemptId,
5203
+ detail: configured.result.error ?? "Workspace configuration failed"
5204
+ } : {
5205
+ phase: "ready",
5206
+ operationId: configured.result.attemptId,
5207
+ detail: `${message.projects.length} project(s) configured`,
5208
+ completedItems: message.projects.length,
5209
+ totalItems: message.projects.length
5210
+ }
5211
+ );
5100
5212
  process.stdout.write(
5101
5213
  `[r5d-worker] workspace configured: ${message.projects.length} project(s), ${configured.pending.length} pending checkout(s)
5102
5214
  `
@@ -5885,6 +5997,21 @@ async function startWorker(options) {
5885
5997
  if (shutdownAfterClose) {
5886
5998
  process.exit(0);
5887
5999
  }
6000
+ if (!startupRecoveryComplete) {
6001
+ process.stderr.write("[r5d-worker] control connection closed during workspace recovery; finishing recovery before reconnecting\n");
6002
+ void startupRecoveryPromise.then(() => {
6003
+ if (!startupRecoveryComplete) {
6004
+ process.exit(1);
6005
+ return;
6006
+ }
6007
+ void startWorker(options).catch((error) => {
6008
+ process.stderr.write(`[r5d-worker] reconnect failed: ${error instanceof Error ? error.message : String(error)}
6009
+ `);
6010
+ process.exit(error instanceof WorkerServerUnavailableError ? WORKER_RECONNECT_EXIT_CODE : 1);
6011
+ });
6012
+ });
6013
+ return;
6014
+ }
5888
6015
  if (activeProcesses.size > 0 || credentialBearingProcessGroups.size > 0 || activePtys.size > 0 || pendingProcessTerminals.size > 0) {
5889
6016
  process.stderr.write(
5890
6017
  `[r5d-worker] ${activeProcesses.size} process(es), ${credentialBearingProcessGroups.size} owned process group(s), and ${activePtys.size} PTY(s) active with ${pendingProcessTerminals.size} terminal report(s) pending; reconnecting in ${WORKER_RECONNECT_DELAY_MS}ms
@@ -5915,6 +6042,11 @@ async function startWorker(options) {
5915
6042
  });
5916
6043
  }
5917
6044
  async function main() {
6045
+ if (process.argv[2] === PROJECT_SNAPSHOT_RECOVERY_HELPER_COMMAND) {
6046
+ const exitCode2 = runProjectSnapshotRecoveryHelper(process.argv.slice(3));
6047
+ if (exitCode2 !== 0) process.exit(exitCode2);
6048
+ return;
6049
+ }
5918
6050
  const parsed = parseArgs(process.argv.slice(2));
5919
6051
  if (parsed.command === "help") {
5920
6052
  printHelp();
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.90",
3
+ "version": "0.0.91",
4
4
  "type": "module"
5
5
  }