@ricsam/r5d-worker 0.0.89 → 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
@@ -7,11 +7,6 @@ import { spawn as spawnChildProcess } from "node:child_process";
7
7
  import { Database } from "bun:sqlite";
8
8
  import { installCliUpdate, readInstalledCliVersion } from "./cli-update.mjs";
9
9
  import { gitTransportSecurityArgs, workerGitProcessEnvironment } from "./git-process-environment.mjs";
10
- import {
11
- grantWorkerHeartbeatBusyGrace,
12
- hasWorkerHeartbeatTimedOutWithBusyGrace,
13
- WORKER_HEARTBEAT_INTERVAL_MS
14
- } from "./heartbeat.mjs";
15
10
  import { terminateProcessTree } from "./process-tree.mjs";
16
11
  import { createPtyOutputCoalescer } from "./pty-output-coalescer.mjs";
17
12
  import { openWorkerPortForwardRelay } from "./port-forward-client.mjs";
@@ -63,7 +58,6 @@ import { assertDisjointManagedRoots, managedProjectRoot, validateManagedBranchNa
63
58
  import {
64
59
  applyObservedProjectMirrorHeads,
65
60
  assertProjectMirrorHeadsPushed,
66
- recoverStaleProjectWorktreeSnapshots,
67
61
  createOrRetryLinkedProjectBranch,
68
62
  deleteLinkedProjectBranch,
69
63
  deleteProjectMirrorBranch,
@@ -76,6 +70,11 @@ import {
76
70
  pushProjectMirrorHeads,
77
71
  removeProjectWorktrees
78
72
  } from "./project-worktrees.mjs";
73
+ import {
74
+ PROJECT_SNAPSHOT_RECOVERY_HELPER_COMMAND,
75
+ recoverProjectSnapshotsInChild,
76
+ runProjectSnapshotRecoveryHelper
77
+ } from "./project-snapshot-recovery-runner.mjs";
79
78
  import {
80
79
  ProjectWorkspacePendingBranchPathChangeError,
81
80
  ProjectWorkspaceStateStore,
@@ -3416,28 +3415,6 @@ async function startWorker(options) {
3416
3415
  runGit(["--version"]);
3417
3416
  await verifyR5dctlAuth(baseUrl, token, label);
3418
3417
  const initializedWorkspaceState = await workspaceSyncSingleFlight.runExclusive(() => {
3419
- if (!startupProjectSnapshotRecoveryCompleted) {
3420
- const snapshotRecovery = recoverStaleProjectWorktreeSnapshots({
3421
- projectsRoot,
3422
- temporaryRoot: projectWorktreeSnapshotsRoot
3423
- });
3424
- if (snapshotRecovery.restored.length > 0) {
3425
- process.stdout.write(`[r5d-worker] restored ${snapshotRecovery.restored.length} interrupted project snapshot(s)
3426
- `);
3427
- }
3428
- if (snapshotRecovery.removed.length > 0) {
3429
- process.stdout.write(`[r5d-worker] removed ${snapshotRecovery.removed.length} completed/incomplete project snapshot(s)
3430
- `);
3431
- }
3432
- for (const failure of snapshotRecovery.failed) {
3433
- process.stderr.write(`[r5d-worker] failed to recover stale project snapshot ${failure.path}: ${failure.error}
3434
- `);
3435
- }
3436
- if (snapshotRecovery.failed.length > 0) {
3437
- throw new Error("Project snapshot recovery is incomplete; refusing to mutate project checkouts");
3438
- }
3439
- startupProjectSnapshotRecoveryCompleted = true;
3440
- }
3441
3418
  fs.mkdirSync(projectsRoot, { recursive: true });
3442
3419
  fs.mkdirSync(syncRoot, { recursive: true });
3443
3420
  fs.mkdirSync(artifactRoot, { recursive: true });
@@ -3476,6 +3453,8 @@ async function startWorker(options) {
3476
3453
  let cliUpdateInProgress = false;
3477
3454
  let reloadAfterClose = false;
3478
3455
  let shutdownAfterClose = false;
3456
+ let startupRecoveryComplete = startupProjectSnapshotRecoveryCompleted;
3457
+ let startupRecoveryPromise = Promise.resolve();
3479
3458
  const automaticWorkspaceSyncIsCurrentlyAdmitted = () => workspaceAutomaticSyncIsAdmitted({
3480
3459
  workspaceConfigured,
3481
3460
  activeIncidentId: activeWorkspaceIncidentId,
@@ -3892,8 +3871,14 @@ async function startWorker(options) {
3892
3871
  })
3893
3872
  )
3894
3873
  ].sort();
3895
- const ensureConfiguredProjects = async () => {
3896
- 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
+ });
3897
3882
  validateProjectId(project.projectId);
3898
3883
  assertRepositoryTransitionState(project);
3899
3884
  for (const branch of project.branches) validateBranchName(branch.branchName);
@@ -3941,7 +3926,14 @@ async function startWorker(options) {
3941
3926
  mirrorUrl: connection.mirrorUrl,
3942
3927
  mirrorCredentialUsername: connection.mirrorCredentialUsername,
3943
3928
  credentialHelper: connection.credentialHelper,
3944
- 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
+ })
3945
3937
  });
3946
3938
  readyProjectIds.add(project.projectId);
3947
3939
  reconciledProjectConfigFingerprints.set(project.projectId, configFingerprint);
@@ -3976,6 +3968,11 @@ async function startWorker(options) {
3976
3968
  `
3977
3969
  );
3978
3970
  }
3971
+ onProgress?.({
3972
+ detail: `Configured ${project.projectPath}`,
3973
+ completedItems: projectIndex + 1,
3974
+ totalItems: projects.length
3975
+ });
3979
3976
  }
3980
3977
  };
3981
3978
  const collectAheadOfOriginBranches = async () => {
@@ -4368,6 +4365,11 @@ async function startWorker(options) {
4368
4365
  if (input.sendResult !== false) sendWorkspaceSyncResult(input.requestId, result2);
4369
4366
  return result2;
4370
4367
  }
4368
+ sendLifecycleProgress({
4369
+ phase: "syncing",
4370
+ operationId: attemptId,
4371
+ detail: input.trigger.detail ?? `Synchronizing workspace (${input.trigger.type})`
4372
+ });
4371
4373
  if (!input.requestId && input.sendResult !== false) {
4372
4374
  if (currentWorkerSocket !== ws || ws.readyState !== WebSocket.OPEN) {
4373
4375
  throw new Error("Cannot start autonomous workspace synchronization without an open worker control socket");
@@ -4470,6 +4472,9 @@ async function startWorker(options) {
4470
4472
  }
4471
4473
  };
4472
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
+ );
4473
4478
  return result;
4474
4479
  };
4475
4480
  const scheduleAutomaticWorkspaceSync = (trigger, delayMs = WORKSPACE_GIT_QUIET_MS) => {
@@ -4509,7 +4514,6 @@ async function startWorker(options) {
4509
4514
  );
4510
4515
  }).finally(() => {
4511
4516
  automaticSyncInFlight = false;
4512
- heartbeatBusyGrace = grantWorkerHeartbeatBusyGrace(lastServerHeartbeatAt, heartbeatBusyGrace);
4513
4517
  if (pendingAutomaticTrigger && automaticWorkspaceSyncIsCurrentlyAdmitted()) {
4514
4518
  scheduleAutomaticWorkspaceSync(pendingAutomaticTrigger, WORKSPACE_GIT_QUIET_MS);
4515
4519
  }
@@ -4545,6 +4549,14 @@ async function startWorker(options) {
4545
4549
  });
4546
4550
  };
4547
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
+ });
4548
4560
  const incidentDeferral = workspaceConfigurationIncidentDeferral({
4549
4561
  requestedIncidentId: message.deferWorkspaceSyncForIncidentId,
4550
4562
  activeIncidentId: activeWorkspaceIncidentId
@@ -4909,7 +4921,13 @@ async function startWorker(options) {
4909
4921
  // checkout initialization from erasing synchronized dirt.
4910
4922
  { ignoreBusy: true }
4911
4923
  );
4912
- 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
+ });
4913
4931
  const configurationSyncAdmissionGeneration = workerAdmissionGeneration;
4914
4932
  const assertConfigurationSyncStillAdmitted = () => {
4915
4933
  if (receiptGeneration !== workspaceConfigurationReceiptGeneration || configurationSyncAdmissionGeneration !== workerAdmissionGeneration || !workspaceConfigurationIncidentSnapshotIsCurrent(incidentDeferral.incidentId, activeWorkspaceIncidentId) || currentWorkerSocket !== ws || ws.readyState !== WebSocket.OPEN) {
@@ -5008,10 +5026,60 @@ async function startWorker(options) {
5008
5026
  Authorization: `Bearer ${token}`
5009
5027
  }
5010
5028
  });
5011
- let lastServerHeartbeatAt = null;
5012
- let heartbeatBusyGrace = null;
5013
- let heartbeatTimedOut = false;
5014
- let heartbeatWatchdog;
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
+ };
5015
5083
  const handleGracefulShutdown = () => {
5016
5084
  if (shutdownAfterClose) return;
5017
5085
  shutdownAfterClose = true;
@@ -5039,25 +5107,29 @@ async function startWorker(options) {
5039
5107
  };
5040
5108
  process.on("SIGTERM", handleGracefulShutdown);
5041
5109
  process.on("SIGINT", handleGracefulShutdown);
5042
- const stopHeartbeatWatchdog = () => {
5043
- if (heartbeatWatchdog) {
5044
- clearInterval(heartbeatWatchdog);
5045
- heartbeatWatchdog = void 0;
5046
- }
5047
- };
5048
5110
  ws.addEventListener("open", () => {
5049
5111
  currentWorkerSocket = ws;
5050
- heartbeatWatchdog = setInterval(() => {
5051
- if (heartbeatTimedOut || !hasWorkerHeartbeatTimedOutWithBusyGrace(lastServerHeartbeatAt, heartbeatBusyGrace)) {
5052
- return;
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
+ });
5053
5122
  }
5054
- heartbeatTimedOut = true;
5055
- stopHeartbeatWatchdog();
5056
- process.stderr.write("[r5d-worker] server heartbeat timed out; reconnecting\n");
5057
- ws.terminate();
5058
- }, WORKER_HEARTBEAT_INTERVAL_MS);
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
+ });
5059
5130
  const hello = {
5060
5131
  type: "hello",
5132
+ startupReady: startupRecoveryComplete,
5061
5133
  hostInfo: {
5062
5134
  hostname: hostname(),
5063
5135
  platform: process.platform,
@@ -5095,6 +5167,8 @@ async function startWorker(options) {
5095
5167
  ws.addEventListener("message", (event) => {
5096
5168
  void (async () => {
5097
5169
  const message = JSON.parse(String(event.data));
5170
+ if (!startupRecoveryComplete && message.type !== "ping") await startupRecoveryPromise;
5171
+ if (!startupRecoveryComplete && message.type !== "ping") return;
5098
5172
  const messageAdmissionGeneration = workerAdmissionGeneration;
5099
5173
  const assertMessageAdmission = () => assertWorkerChildAdmission({
5100
5174
  capturedGeneration: messageAdmissionGeneration,
@@ -5102,7 +5176,7 @@ async function startWorker(options) {
5102
5176
  sourceSocketIsCurrent: currentWorkerSocket === ws,
5103
5177
  sourceSocketOpen: ws.readyState === WebSocket.OPEN,
5104
5178
  workspaceConfigured,
5105
- runtimeHealthy: !shutdownAfterClose && !heartbeatTimedOut
5179
+ runtimeHealthy: !shutdownAfterClose
5106
5180
  });
5107
5181
  if (message.type === "connected") {
5108
5182
  return;
@@ -5122,6 +5196,19 @@ async function startWorker(options) {
5122
5196
  aheadOfOriginBranches: configured.aheadOfOriginBranches,
5123
5197
  ...configured.deferredWorkspaceSyncForIncidentId ? { deferredWorkspaceSyncForIncidentId: configured.deferredWorkspaceSyncForIncidentId } : {}
5124
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
+ );
5125
5212
  process.stdout.write(
5126
5213
  `[r5d-worker] workspace configured: ${message.projects.length} project(s), ${configured.pending.length} pending checkout(s)
5127
5214
  `
@@ -5461,8 +5548,6 @@ async function startWorker(options) {
5461
5548
  return;
5462
5549
  }
5463
5550
  if (message.type === "ping") {
5464
- lastServerHeartbeatAt = Date.now();
5465
- heartbeatBusyGrace = null;
5466
5551
  ws.send(JSON.stringify({ type: "pong" }));
5467
5552
  return;
5468
5553
  }
@@ -5876,15 +5961,12 @@ async function startWorker(options) {
5876
5961
  })().catch((error) => {
5877
5962
  process.stderr.write(`[r5d-worker] message handling failed: ${error instanceof Error ? error.message : String(error)}
5878
5963
  `);
5879
- }).finally(() => {
5880
- heartbeatBusyGrace = grantWorkerHeartbeatBusyGrace(lastServerHeartbeatAt, heartbeatBusyGrace);
5881
5964
  });
5882
5965
  });
5883
5966
  ws.addEventListener("close", (event) => {
5884
5967
  const processEvents = process;
5885
5968
  processEvents.off("SIGTERM", handleGracefulShutdown);
5886
5969
  processEvents.off("SIGINT", handleGracefulShutdown);
5887
- stopHeartbeatWatchdog();
5888
5970
  if (workspaceAutomaticTimer) {
5889
5971
  clearTimeout(workspaceAutomaticTimer);
5890
5972
  workspaceAutomaticTimer = void 0;
@@ -5915,6 +5997,21 @@ async function startWorker(options) {
5915
5997
  if (shutdownAfterClose) {
5916
5998
  process.exit(0);
5917
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
+ }
5918
6015
  if (activeProcesses.size > 0 || credentialBearingProcessGroups.size > 0 || activePtys.size > 0 || pendingProcessTerminals.size > 0) {
5919
6016
  process.stderr.write(
5920
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
@@ -5945,6 +6042,11 @@ async function startWorker(options) {
5945
6042
  });
5946
6043
  }
5947
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
+ }
5948
6050
  const parsed = parseArgs(process.argv.slice(2));
5949
6051
  if (parsed.command === "help") {
5950
6052
  printHelp();