@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.
- package/README.md +4 -0
- package/dist/cjs/main.cjs +155 -52
- package/dist/cjs/package.json +1 -1
- package/dist/cjs/project-snapshot-recovery-runner.cjs +171 -0
- package/dist/cjs/project-worktrees.cjs +183 -23
- package/dist/mjs/main.mjs +159 -57
- package/dist/mjs/package.json +1 -1
- package/dist/mjs/project-snapshot-recovery-runner.mjs +135 -0
- package/dist/mjs/project-worktrees.mjs +183 -23
- package/dist/types/project-snapshot-recovery-runner.d.ts +10 -0
- package/dist/types/project-worktrees.d.ts +13 -1
- package/package.json +1 -1
- package/dist/cjs/heartbeat.cjs +0 -49
- package/dist/mjs/heartbeat.mjs +0 -21
- package/dist/types/heartbeat.d.ts +0 -10
|
@@ -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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
|
|
548
|
+
function restoreProjectWorktreeSnapshot(snapshotRoot, manifest, onProgress) {
|
|
549
|
+
let current = readSnapshotManifest(snapshotRoot);
|
|
492
550
|
assertSameSnapshotIdentity(manifest, current);
|
|
493
|
-
if (current.state !== "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
|
-
|
|
498
|
-
|
|
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
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
5012
|
-
|
|
5013
|
-
|
|
5014
|
-
|
|
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
|
-
|
|
5051
|
-
|
|
5052
|
-
|
|
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
|
-
|
|
5055
|
-
|
|
5056
|
-
|
|
5057
|
-
|
|
5058
|
-
|
|
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
|
|
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();
|