@ricsam/r5d-worker 0.0.103 → 0.0.105

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/dist/cjs/main.cjs CHANGED
@@ -3509,6 +3509,7 @@ async function startWorker(options) {
3509
3509
  let terminalReplayTimer;
3510
3510
  let workspaceSyncRequestsInFlight = 0;
3511
3511
  let lastWorkspaceSyncMirrorObservationMs = 0;
3512
+ let lastInventoriedMissingCheckouts;
3512
3513
  let sessionArtifactSyncRequestsInFlight = 0;
3513
3514
  let cliUpdateInProgress = false;
3514
3515
  let reloadAfterClose = false;
@@ -3566,8 +3567,15 @@ async function startWorker(options) {
3566
3567
  const primaryBranchName = primaryProjectBranch(project);
3567
3568
  const connection = projectConnection(project);
3568
3569
  const sourcePath = configuredProjectBranchPath(projectsRoot, project, branchName);
3569
- if (requireLocalBranch || hasProjectWorktree(sourcePath)) {
3570
- (0, import_project_worktrees.deleteLinkedProjectBranch)({ projectRoot, primaryBranchName, branchName });
3570
+ const primaryPath = configuredProjectBranchPath(projectsRoot, project, primaryBranchName);
3571
+ if (requireLocalBranch || import_node_fs.default.existsSync(sourcePath) && hasProjectWorktree(primaryPath)) {
3572
+ const deleted = (0, import_project_worktrees.deleteLinkedProjectBranch)({ projectRoot, primaryBranchName, branchName });
3573
+ if (deleted.movedAsidePath) {
3574
+ process.stderr.write(
3575
+ `[r5d-worker] set aside ${sourcePath}, which was not a linked worktree of ${project.projectPath}, at ${deleted.movedAsidePath}
3576
+ `
3577
+ );
3578
+ }
3571
3579
  }
3572
3580
  const planSourcePath = import_node_path.default.join(planRoot, project.projectId, ...branchName.split("/"));
3573
3581
  import_node_fs.default.rmSync(planSourcePath, { recursive: true, force: true });
@@ -3640,11 +3648,17 @@ async function startWorker(options) {
3640
3648
  if (branchName === primaryBranchName) {
3641
3649
  throw new Error(`Cannot hide preserve-only project branch ${project.projectId}/${branchName} because it is the active anchor`);
3642
3650
  }
3643
- (0, import_project_worktrees.deleteLinkedProjectBranch)({
3651
+ const deleted = (0, import_project_worktrees.deleteLinkedProjectBranch)({
3644
3652
  projectRoot: configuredProjectRoot(projectsRoot, project),
3645
3653
  primaryBranchName,
3646
3654
  branchName
3647
3655
  });
3656
+ if (deleted.movedAsidePath) {
3657
+ process.stderr.write(
3658
+ `[r5d-worker] set aside ${branchPath}, which was not a linked worktree of ${project.projectPath}, at ${deleted.movedAsidePath}
3659
+ `
3660
+ );
3661
+ }
3648
3662
  import_node_fs.default.rmSync(import_node_path.default.join(planRoot, project.projectId, ...branchName.split("/")), { recursive: true, force: true });
3649
3663
  readyProjectIds.delete(project.projectId);
3650
3664
  reconciledProjectConfigFingerprints.delete(project.projectId);
@@ -3813,6 +3827,40 @@ async function startWorker(options) {
3813
3827
  });
3814
3828
  };
3815
3829
  const projectBranchMountBusy = (projectId, branchName, branchPath) => !readyProjectIds.has(projectId) || projectBranchMountActivityBusy(projectId, branchName, branchPath);
3830
+ const inventoryConfiguredProjectCheckouts = () => {
3831
+ const inventory = { projects: [], missingCheckouts: [] };
3832
+ for (const project of projectConfigById.values()) {
3833
+ if (project.executionDisabled || !readyProjectIds.has(project.projectId) || project.branches.length === 0) {
3834
+ inventory.projects.push(project);
3835
+ continue;
3836
+ }
3837
+ const checkouts = (0, import_project_worktrees.inventoryProjectCheckouts)({
3838
+ projectRoot: configuredProjectRoot(projectsRoot, project),
3839
+ primaryBranchName: primaryProjectBranch(project),
3840
+ branchNames: project.branches.map(({ branchName }) => branchName)
3841
+ });
3842
+ if (checkouts.missingCheckouts.length === 0) {
3843
+ inventory.projects.push(project);
3844
+ continue;
3845
+ }
3846
+ for (const missing of checkouts.missingCheckouts) inventory.missingCheckouts.push({ projectId: project.projectId, ...missing });
3847
+ const linked = new Set(checkouts.linkedBranchNames);
3848
+ inventory.projects.push({ ...project, branches: project.branches.filter(({ branchName }) => linked.has(branchName)) });
3849
+ }
3850
+ inventory.missingCheckouts.sort(
3851
+ (left, right) => left.projectId.localeCompare(right.projectId) || left.branchName.localeCompare(right.branchName)
3852
+ );
3853
+ return inventory;
3854
+ };
3855
+ const reportedMissingCheckouts = (inventory) => inventory.missingCheckouts.map(({ projectId, branchName, reason }) => ({ projectId, branchName, reason }));
3856
+ const logMissingCheckouts = (inventory) => {
3857
+ if (inventory.missingCheckouts.length === 0) return;
3858
+ const described = inventory.missingCheckouts.map(({ checkoutPath, reason }) => `${checkoutPath} (${reason === "missing" ? "directory missing" : "not a linked worktree of the project"})`).join(", ");
3859
+ process.stderr.write(
3860
+ `[r5d-worker] skipping ${inventory.missingCheckouts.length} configured project checkout(s) this cycle: ${described}; delete the branch through the platform or reconnect this worker to recreate the checkout
3861
+ `
3862
+ );
3863
+ };
3816
3864
  const buildWorkspaceMounts = (projects = projectConfigById.values()) => {
3817
3865
  const mounts = [];
3818
3866
  const creatorLocalIncarnations = new Map(
@@ -3938,6 +3986,7 @@ async function startWorker(options) {
3938
3986
  )
3939
3987
  ].sort();
3940
3988
  const ensureConfiguredProjects = async (onProgress) => {
3989
+ const reconciledProjectIds = /* @__PURE__ */ new Set();
3941
3990
  const projects = [...projectConfigById.values()];
3942
3991
  for (const [projectIndex, project] of projects.entries()) {
3943
3992
  onProgress?.({
@@ -4003,6 +4052,7 @@ async function startWorker(options) {
4003
4052
  });
4004
4053
  readyProjectIds.add(project.projectId);
4005
4054
  reconciledProjectConfigFingerprints.set(project.projectId, configFingerprint);
4055
+ reconciledProjectIds.add(project.projectId);
4006
4056
  for (const state of states) {
4007
4057
  pendingCheckouts.delete(projectBranchKey(project.projectId, state.branchName));
4008
4058
  const key = projectBranchKey(project.projectId, state.branchName);
@@ -4040,26 +4090,37 @@ async function startWorker(options) {
4040
4090
  totalItems: projects.length
4041
4091
  });
4042
4092
  }
4093
+ return reconciledProjectIds;
4043
4094
  };
4044
- const collectAheadOfOriginBranches = async () => {
4095
+ const ORIGIN_DIVERGENCE_REFRESH_INTERVAL_MS = 5 * 60 * 1e3;
4096
+ const lastOriginDivergenceFetchMs = /* @__PURE__ */ new Map();
4097
+ const collectAheadOfOriginBranches = async (refetchOriginProjectIds) => {
4045
4098
  const aheadBranches = [];
4099
+ for (const projectId of [...lastOriginDivergenceFetchMs.keys()]) {
4100
+ if (!projectConfigById.has(projectId)) lastOriginDivergenceFetchMs.delete(projectId);
4101
+ }
4046
4102
  for (const project of projectConfigById.values()) {
4047
4103
  if (project.executionDisabled || !readyProjectIds.has(project.projectId) || project.branches.length === 0) continue;
4048
4104
  const connection = projectConnection(project);
4049
4105
  const primaryPath = configuredProjectBranchPath(projectsRoot, project, primaryProjectBranch(project));
4050
- if (!await tryGitAsync(
4051
- [
4052
- ...(0, import_git_process_environment.gitTransportSecurityArgs)(connection.originUrl, connection.credentialHelper, connection.originCredentialUsername),
4053
- "fetch",
4054
- "--no-recurse-submodules",
4055
- "--prune",
4056
- "origin",
4057
- "+refs/heads/*:refs/remotes/origin/*"
4058
- ],
4059
- { cwd: primaryPath }
4060
- )) {
4061
- process.stderr.write(`[r5d-worker] could not refresh origin divergence for ${project.projectPath}
4106
+ const lastFetchedAtMs = lastOriginDivergenceFetchMs.get(project.projectId);
4107
+ if (refetchOriginProjectIds.has(project.projectId) || lastFetchedAtMs === void 0 || Date.now() - lastFetchedAtMs >= ORIGIN_DIVERGENCE_REFRESH_INTERVAL_MS) {
4108
+ if (await tryGitAsync(
4109
+ [
4110
+ ...(0, import_git_process_environment.gitTransportSecurityArgs)(connection.originUrl, connection.credentialHelper, connection.originCredentialUsername),
4111
+ "fetch",
4112
+ "--no-recurse-submodules",
4113
+ "--prune",
4114
+ "origin",
4115
+ "+refs/heads/*:refs/remotes/origin/*"
4116
+ ],
4117
+ { cwd: primaryPath }
4118
+ )) {
4119
+ lastOriginDivergenceFetchMs.set(project.projectId, Date.now());
4120
+ } else {
4121
+ process.stderr.write(`[r5d-worker] could not refresh origin divergence for ${project.projectPath}
4062
4122
  `);
4123
+ }
4063
4124
  }
4064
4125
  for (const branch of project.branches) {
4065
4126
  const branchPath = configuredProjectBranchPath(projectsRoot, project, branch.branchName);
@@ -4091,9 +4152,9 @@ async function startWorker(options) {
4091
4152
  (left, right) => left.projectId.localeCompare(right.projectId) || left.branchName.localeCompare(right.branchName)
4092
4153
  );
4093
4154
  };
4094
- const captureProjectHeadsForWorkspacePublication = async () => {
4155
+ const captureProjectHeadsForWorkspacePublication = async (projects = projectConfigById.values()) => {
4095
4156
  const heads = /* @__PURE__ */ new Map();
4096
- for (const project of projectConfigById.values()) {
4157
+ for (const project of projects) {
4097
4158
  if (!readyProjectIds.has(project.projectId)) continue;
4098
4159
  for (const branch of project.branches) {
4099
4160
  const branchPath = configuredProjectBranchPath(projectsRoot, project, branch.branchName);
@@ -4103,9 +4164,9 @@ async function startWorker(options) {
4103
4164
  }
4104
4165
  return heads;
4105
4166
  };
4106
- const pushChangedProjectHeads = async (activeMountIds, publishedHead, publicationHeads, expectedMirrorHeads, suppressedMountIds = /* @__PURE__ */ new Set()) => {
4167
+ const pushChangedProjectHeads = async (activeMountIds, publishedHead, publicationHeads, expectedMirrorHeads, suppressedMountIds = /* @__PURE__ */ new Set(), projects = projectConfigById.values()) => {
4107
4168
  const active = new Set(activeMountIds);
4108
- for (const project of projectConfigById.values()) {
4169
+ for (const project of projects) {
4109
4170
  if (!readyProjectIds.has(project.projectId) || !(0, import_repository_transition_policy.repositoryMirrorWritesAllowed)(project)) continue;
4110
4171
  const changed = /* @__PURE__ */ new Map();
4111
4172
  for (const branch of project.branches) {
@@ -4205,7 +4266,7 @@ async function startWorker(options) {
4205
4266
  };
4206
4267
  const observeProjectHeadsBeforeOuterWorkspace = async (input) => {
4207
4268
  const observationStartedAt = Date.now();
4208
- const eligibleProjects = [...projectConfigById.values()].filter(
4269
+ const eligibleProjects = [...input.projects ?? projectConfigById.values()].filter(
4209
4270
  (project) => !project.executionDisabled && readyProjectIds.has(project.projectId) && project.branches.length > 0
4210
4271
  );
4211
4272
  try {
@@ -4315,7 +4376,7 @@ async function startWorker(options) {
4315
4376
  }
4316
4377
  }
4317
4378
  };
4318
- const mapWorkspaceGitResult = async (attemptId, trigger, result) => {
4379
+ const mapWorkspaceGitResult = async (attemptId, trigger, result, missingCheckouts) => {
4319
4380
  const activeProjectBranchPublications = (0, import_workspace_publication_evidence.activeProjectBranchPublicationEvidence)([...projectConfigById.values()], result.activeMountIds);
4320
4381
  return {
4321
4382
  type: "workspace_sync",
@@ -4333,6 +4394,7 @@ async function startWorker(options) {
4333
4394
  affectedPaths: result.affectedPaths,
4334
4395
  activeMountIds: result.activeMountIds,
4335
4396
  skippedMountIds: result.skippedMountIds,
4397
+ ...missingCheckouts ? { missingCheckouts } : {},
4336
4398
  activeProjectBranchPublications,
4337
4399
  discardedPaths: [],
4338
4400
  localChangesDiscarded: false,
@@ -4360,6 +4422,7 @@ async function startWorker(options) {
4360
4422
  affectedPaths: [],
4361
4423
  discardedPaths: [],
4362
4424
  localChangesDiscarded: false,
4425
+ ...lastInventoriedMissingCheckouts ? { missingCheckouts: lastInventoriedMissingCheckouts } : {},
4363
4426
  error: error instanceof Error ? error.message : String(error)
4364
4427
  };
4365
4428
  };
@@ -4368,7 +4431,11 @@ async function startWorker(options) {
4368
4431
  if (!workspaceRemoteUrl || !workspaceCredentialHelper || !workspaceCredentialUsername || !workspaceGitIdentity) {
4369
4432
  throw new Error("Worker workspace configuration has not been received");
4370
4433
  }
4371
- const configuredMounts = buildWorkspaceMounts();
4434
+ const checkoutInventory = inventoryConfiguredProjectCheckouts();
4435
+ logMissingCheckouts(checkoutInventory);
4436
+ const missingCheckouts = reportedMissingCheckouts(checkoutInventory);
4437
+ lastInventoriedMissingCheckouts = missingCheckouts;
4438
+ const configuredMounts = buildWorkspaceMounts(checkoutInventory.projects);
4372
4439
  if (input.resetToCanonical) {
4373
4440
  workspaceProjectionLedger.reset();
4374
4441
  const resetHasBusyLiveMount = configuredMounts.some(
@@ -4379,7 +4446,8 @@ async function startWorker(options) {
4379
4446
  }
4380
4447
  const observedProjectHeads2 = await observeProjectHeadsBeforeOuterWorkspace({
4381
4448
  allowNonFastForward: true,
4382
- failClosed: true
4449
+ failClosed: true,
4450
+ projects: checkoutInventory.projects
4383
4451
  });
4384
4452
  const reset = await (0, import_workspace_git_sync.resetWorkspaceGit)({
4385
4453
  workspacePath: workspaceShadowRoot,
@@ -4407,6 +4475,7 @@ async function startWorker(options) {
4407
4475
  affectedPaths: reset.discardedPaths,
4408
4476
  activeMountIds: reset.activeMountIds,
4409
4477
  skippedMountIds: reset.skippedMountIds,
4478
+ missingCheckouts,
4410
4479
  discardedPaths: reset.discardedPaths,
4411
4480
  localChangesDiscarded: reset.discardedPaths.length > 0 || reset.startingHead !== reset.remoteHead
4412
4481
  };
@@ -4414,7 +4483,7 @@ async function startWorker(options) {
4414
4483
  const outerRemediation = input.trigger.type === "remediation" || input.trigger.type === "remediation_confirm";
4415
4484
  const mounts = outerRemediation ? (0, import_workspace_git_sync.workspaceGitMountsForRemediation)(configuredMounts) : configuredMounts;
4416
4485
  if (outerRemediation) workspaceProjectionLedger.reset();
4417
- const observedProjectHeads = outerRemediation ? [] : await observeProjectHeadsBeforeOuterWorkspace({ allowNonFastForward: false, failClosed: false });
4486
+ const observedProjectHeads = outerRemediation ? [] : await observeProjectHeadsBeforeOuterWorkspace({ allowNonFastForward: false, failClosed: false, projects: checkoutInventory.projects });
4418
4487
  const expectedMirrorHeads = /* @__PURE__ */ new Map();
4419
4488
  for (const bundle of observedProjectHeads) {
4420
4489
  for (const observation of bundle.observations) {
@@ -4426,7 +4495,7 @@ async function startWorker(options) {
4426
4495
  ({ projectId, observations }) => observations.filter(({ shouldMove }) => shouldMove).map(({ branchName }) => projectMountId(projectId, branchName))
4427
4496
  )
4428
4497
  );
4429
- const publicationHeads = outerRemediation ? /* @__PURE__ */ new Map() : await captureProjectHeadsForWorkspacePublication();
4498
+ const publicationHeads = outerRemediation ? /* @__PURE__ */ new Map() : await captureProjectHeadsForWorkspacePublication(checkoutInventory.projects);
4430
4499
  const result = await (0, import_workspace_git_sync.synchronizeWorkspaceGit)({
4431
4500
  attemptId: input.attemptId,
4432
4501
  workerLabel: label,
@@ -4446,7 +4515,14 @@ async function startWorker(options) {
4446
4515
  advertisedRemoteHead: projectMirrorFetchSkippingEnabled && !outerRemediation ? advertisedWorkspaceHead : void 0,
4447
4516
  assertStillAdmitted: input.assertStillAdmitted,
4448
4517
  afterWorkspacePublished: outerRemediation ? void 0 : async ({ activeMountIds, publishedHead }) => {
4449
- await pushChangedProjectHeads(activeMountIds, publishedHead, publicationHeads, expectedMirrorHeads, inboundMoveMountIds);
4518
+ await pushChangedProjectHeads(
4519
+ activeMountIds,
4520
+ publishedHead,
4521
+ publicationHeads,
4522
+ expectedMirrorHeads,
4523
+ inboundMoveMountIds,
4524
+ checkoutInventory.projects
4525
+ );
4450
4526
  },
4451
4527
  hydrationHooks: workspaceHydrationTransactionHooks
4452
4528
  });
@@ -4465,7 +4541,7 @@ async function startWorker(options) {
4465
4541
  result.localHead ?? result.remoteHead
4466
4542
  );
4467
4543
  }
4468
- const mapped = await mapWorkspaceGitResult(input.attemptId, input.trigger, result);
4544
+ const mapped = await mapWorkspaceGitResult(input.attemptId, input.trigger, result, missingCheckouts);
4469
4545
  if (!outerRemediation) {
4470
4546
  workspaceProjectionLedger.recordCycle({ result: mapped, mounts, projectedMutationTokens: result.projectedMutationTokens });
4471
4547
  }
@@ -5175,7 +5251,7 @@ async function startWorker(options) {
5175
5251
  // checkout initialization from erasing synchronized dirt.
5176
5252
  { ignoreBusy: true, hydrationHooks: workspaceHydrationTransactionHooks }
5177
5253
  );
5178
- await ensureConfiguredProjects((progress) => {
5254
+ const reconciledProjectIds = await ensureConfiguredProjects((progress) => {
5179
5255
  sendLifecycleProgress({ phase: "configuring", operationId: configurationOperationId, ...progress });
5180
5256
  process.stdout.write(
5181
5257
  `[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)`}
@@ -5231,7 +5307,7 @@ async function startWorker(options) {
5231
5307
  const pending = [...pendingCheckouts.values()].sort(
5232
5308
  (left, right) => left.projectId.localeCompare(right.projectId) || left.branchName.localeCompare(right.branchName)
5233
5309
  );
5234
- return { result, pending, aheadOfOriginBranches: await collectAheadOfOriginBranches() };
5310
+ return { result, pending, aheadOfOriginBranches: await collectAheadOfOriginBranches(reconciledProjectIds) };
5235
5311
  });
5236
5312
  } catch (error) {
5237
5313
  if (receiptGeneration !== workspaceConfigurationReceiptGeneration) {
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.103",
3
+ "version": "0.0.105",
4
4
  "type": "commonjs"
5
5
  }
@@ -28,10 +28,12 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
28
28
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
29
  var project_worktrees_exports = {};
30
30
  __export(project_worktrees_exports, {
31
+ PROJECT_REMOVED_CHECKOUTS_DIRECTORY: () => PROJECT_REMOVED_CHECKOUTS_DIRECTORY,
31
32
  PROJECT_WORKTREE_SNAPSHOT_PREFIX: () => PROJECT_WORKTREE_SNAPSHOT_PREFIX,
32
33
  ProjectBranchCreationRollbackIncompleteError: () => ProjectBranchCreationRollbackIncompleteError,
33
34
  applyObservedProjectMirrorHeads: () => applyObservedProjectMirrorHeads,
34
35
  assertProjectMirrorHeadsPushed: () => assertProjectMirrorHeadsPushed,
36
+ classifyProjectCheckout: () => classifyProjectCheckout,
35
37
  computeLocalProjectMirrorRefsTokens: () => computeLocalProjectMirrorRefsTokens,
36
38
  createLinkedProjectBranch: () => createLinkedProjectBranch,
37
39
  createOrRetryLinkedProjectBranch: () => createOrRetryLinkedProjectBranch,
@@ -40,6 +42,7 @@ __export(project_worktrees_exports, {
40
42
  ensureProjectWorktrees: () => ensureProjectWorktrees,
41
43
  fastForwardProjectHeadsFromMirror: () => fastForwardProjectHeadsFromMirror,
42
44
  hasLinkedProjectWorktreeLayout: () => hasLinkedProjectWorktreeLayout,
45
+ inventoryProjectCheckouts: () => inventoryProjectCheckouts,
43
46
  observeProjectMirrorHeads: () => observeProjectMirrorHeads,
44
47
  projectBranchMayExistAfterCreateFailure: () => projectBranchMayExistAfterCreateFailure,
45
48
  projectOriginBranchName: () => projectOriginBranchName,
@@ -334,6 +337,8 @@ function normalizeSnapshotManifest(value) {
334
337
  if (record.state !== "building" && record.state !== "prepared" && record.state !== "restoring" && record.state !== "consumed") {
335
338
  throw new Error("Project worktree snapshot manifest has an invalid state");
336
339
  }
340
+ const scope = record.scope === void 0 ? "project" : record.scope;
341
+ if (scope !== "project" && scope !== "branches") throw new Error("Project worktree snapshot manifest has an invalid scope");
337
342
  if (!Number.isSafeInteger(record.ownerProcessId) || record.ownerProcessId <= 0) {
338
343
  throw new Error("Project worktree snapshot manifest has an invalid owner process");
339
344
  }
@@ -369,6 +374,7 @@ function normalizeSnapshotManifest(value) {
369
374
  return {
370
375
  version: PROJECT_WORKTREE_SNAPSHOT_MANIFEST_VERSION,
371
376
  state: record.state,
377
+ scope,
372
378
  ownerProcessId: record.ownerProcessId,
373
379
  ownerSessionId: record.ownerSessionId,
374
380
  projectRoot: record.projectRoot,
@@ -405,7 +411,7 @@ function writeSnapshotManifest(snapshotRoot, manifest) {
405
411
  return readSnapshotManifest(snapshotRoot);
406
412
  }
407
413
  function assertSameSnapshotIdentity(expected, actual) {
408
- if (expected.ownerProcessId !== actual.ownerProcessId || expected.ownerSessionId !== actual.ownerSessionId || expected.projectRoot !== actual.projectRoot || expected.createdAt !== actual.createdAt || JSON.stringify(expected.branches) !== JSON.stringify(actual.branches)) {
414
+ if (expected.scope !== actual.scope || expected.ownerProcessId !== actual.ownerProcessId || expected.ownerSessionId !== actual.ownerSessionId || expected.projectRoot !== actual.projectRoot || expected.createdAt !== actual.createdAt || JSON.stringify(expected.branches) !== JSON.stringify(actual.branches)) {
409
415
  throw new Error("Project worktree snapshot identity changed");
410
416
  }
411
417
  }
@@ -472,7 +478,7 @@ function assertNoOutstandingSnapshotForProject(temporaryRoot, projectRoot) {
472
478
  }
473
479
  }
474
480
  }
475
- function snapshotBranchTrees(projectRoot, branches, temporaryRoot, onProgress) {
481
+ function snapshotBranchTrees(projectRoot, branches, temporaryRoot, onProgress, scope = "project") {
476
482
  const resolvedProjectRoot = import_node_path.default.resolve(projectRoot);
477
483
  const resolvedTemporaryRoot = import_node_path.default.resolve(temporaryRoot ?? import_node_os.default.tmpdir());
478
484
  assertNoOutstandingSnapshotForProject(resolvedTemporaryRoot, resolvedProjectRoot);
@@ -493,6 +499,7 @@ function snapshotBranchTrees(projectRoot, branches, temporaryRoot, onProgress) {
493
499
  let manifest = writeSnapshotManifest(root, {
494
500
  version: PROJECT_WORKTREE_SNAPSHOT_MANIFEST_VERSION,
495
501
  state: "building",
502
+ scope,
496
503
  ownerProcessId: process.pid,
497
504
  ownerSessionId: PROJECT_WORKTREE_SNAPSHOT_OWNER_SESSION_ID,
498
505
  projectRoot: resolvedProjectRoot,
@@ -599,7 +606,7 @@ function restoreProjectWorktreeSnapshot(snapshotRoot, manifest, onProgress) {
599
606
  const presentBranches = current.branches.filter(({ snapshotPresent }) => snapshotPresent);
600
607
  const destinationParent = import_node_path.default.dirname(current.projectRoot);
601
608
  import_node_fs.default.mkdirSync(destinationParent, { recursive: true });
602
- const canMoveWholeSnapshot = presentBranches.length > 0 && (current.state === "restoring" || treesRootStat !== null && treesRootStat.dev === import_node_fs.default.lstatSync(destinationParent).dev);
609
+ const canMoveWholeSnapshot = current.scope === "project" && presentBranches.length > 0 && (current.state === "restoring" || treesRootStat !== null && treesRootStat.dev === import_node_fs.default.lstatSync(destinationParent).dev);
603
610
  if (canMoveWholeSnapshot) {
604
611
  if (current.state === "prepared") current = advanceSnapshotState(snapshotRoot, current, "restoring");
605
612
  onProgress?.({
@@ -690,8 +697,12 @@ function consumeAndRemoveProjectWorktreeSnapshot(snapshotRoot, manifest) {
690
697
  } catch {
691
698
  }
692
699
  }
693
- function persistReconciledProjectAndConsumeSnapshot(snapshot, afterProjectTreeFsync) {
694
- fsyncTree(snapshot.manifest.projectRoot);
700
+ function persistReconciledProjectAndConsumeSnapshot(snapshot, afterProjectTreeFsync, durabilityPaths) {
701
+ if (durabilityPaths) {
702
+ for (const durabilityPath of durabilityPaths) if (import_node_fs.default.existsSync(durabilityPath)) fsyncTree(durabilityPath);
703
+ } else {
704
+ fsyncTree(snapshot.manifest.projectRoot);
705
+ }
695
706
  fsyncManagedProjectRootEntry(snapshot.manifest.projectRoot);
696
707
  afterProjectTreeFsync?.();
697
708
  consumeAndRemoveProjectWorktreeSnapshot(snapshot.root, snapshot.manifest);
@@ -989,35 +1000,45 @@ async function reconcileLinkedLayout(input) {
989
1000
  await configureRepository({ ...input, primaryPath });
990
1001
  await tryGitAsync(primaryPath, ["worktree", "prune"]);
991
1002
  await fetchProjectHeads({ ...input, primaryPath });
1003
+ const primaryCommonDir = await commonGitDirectoryAsync(primaryPath);
1004
+ if (!primaryCommonDir) throw new Error("Primary project checkout is unavailable");
992
1005
  const mirrorHeads = /* @__PURE__ */ new Map();
993
- const mutatedBranches = /* @__PURE__ */ new Set();
1006
+ const plan = [];
994
1007
  for (const branch of input.branches) {
995
1008
  const checkoutPath = branchPath(input.projectRoot, branch.branchName);
1009
+ const exactMirror = input.exactMirrorBranches?.has(branch.branchName) === true;
996
1010
  const resolved = await seedForBranch({
997
1011
  ...input,
998
1012
  primaryPath,
999
1013
  branch,
1000
- requireMirrorHead: input.exactMirrorBranches?.has(branch.branchName)
1014
+ requireMirrorHead: exactMirror
1001
1015
  });
1002
1016
  mirrorHeads.set(branch.branchName, resolved.mirrorHead);
1003
- if (branch.branchName === input.primaryBranchName || await commonGitDirectoryAsync(checkoutPath) === await commonGitDirectoryAsync(primaryPath)) {
1004
- const currentHead = await gitAsync(checkoutPath, ["rev-parse", "HEAD"], `resolve linked worktree head ${branch.branchName}`);
1005
- if (currentHead === resolved.seed) continue;
1006
- input.beforeDestructiveMutation();
1007
- mutatedBranches.add(branch.branchName);
1008
- await preserveHead(checkoutPath);
1009
- await gitAsync(checkoutPath, ["reset", "--hard", resolved.seed], `anchor linked worktree ${branch.branchName}`);
1017
+ const linked = branch.branchName === input.primaryBranchName || gitDirectoryKind(checkoutPath) !== null && await commonGitDirectoryAsync(checkoutPath) === primaryCommonDir;
1018
+ if (!linked) {
1019
+ plan.push({ branchName: branch.branchName, checkoutPath, seed: resolved.seed, action: "recreate" });
1010
1020
  continue;
1011
1021
  }
1012
- input.beforeDestructiveMutation();
1013
- mutatedBranches.add(branch.branchName);
1014
- import_node_fs.default.rmSync(checkoutPath, { recursive: true, force: true });
1015
- await gitAsync(primaryPath, ["branch", "-f", branch.branchName, resolved.seed], `anchor project branch ${branch.branchName}`);
1016
- import_node_fs.default.mkdirSync(import_node_path.default.dirname(checkoutPath), { recursive: true });
1022
+ const currentHead = await gitAsync(checkoutPath, ["rev-parse", "HEAD"], `resolve linked worktree head ${branch.branchName}`);
1023
+ const keep = currentHead === resolved.seed || !exactMirror && await tryGitAsync(checkoutPath, ["merge-base", "--is-ancestor", resolved.seed, currentHead]);
1024
+ plan.push({ branchName: branch.branchName, checkoutPath, seed: resolved.seed, action: keep ? "keep" : "reset" });
1025
+ }
1026
+ const mutatedBranches = new Set(plan.filter(({ action }) => action !== "keep").map(({ branchName }) => branchName));
1027
+ if (mutatedBranches.size > 0) input.beforeDestructiveMutation(mutatedBranches);
1028
+ for (const entry of plan) {
1029
+ if (entry.action === "keep") continue;
1030
+ if (entry.action === "reset") {
1031
+ await preserveHead(entry.checkoutPath);
1032
+ await gitAsync(entry.checkoutPath, ["reset", "--hard", entry.seed], `anchor linked worktree ${entry.branchName}`);
1033
+ continue;
1034
+ }
1035
+ import_node_fs.default.rmSync(entry.checkoutPath, { recursive: true, force: true });
1036
+ await gitAsync(primaryPath, ["branch", "-f", entry.branchName, entry.seed], `anchor project branch ${entry.branchName}`);
1037
+ import_node_fs.default.mkdirSync(import_node_path.default.dirname(entry.checkoutPath), { recursive: true });
1017
1038
  await gitAsync(
1018
1039
  primaryPath,
1019
- ["worktree", "add", "--force", checkoutPath, branch.branchName],
1020
- `create linked worktree ${branch.branchName}`
1040
+ ["worktree", "add", "--force", entry.checkoutPath, entry.branchName],
1041
+ `create linked worktree ${entry.branchName}`
1021
1042
  );
1022
1043
  }
1023
1044
  return { mirrorHeads, mutatedBranches };
@@ -1044,8 +1065,14 @@ async function ensureProjectWorktrees(input) {
1044
1065
  const snapshotStorageRoot = import_node_path.default.resolve(input.snapshotRoot ?? import_node_os.default.tmpdir());
1045
1066
  assertNoOutstandingSnapshotForProject(snapshotStorageRoot, projectRoot);
1046
1067
  let snapshot;
1047
- const ensureSnapshot = () => {
1048
- snapshot ??= snapshotBranchTrees(projectRoot, input.branches, snapshotStorageRoot, input.onSnapshotProgress);
1068
+ const ensureSnapshot = (branchNames) => {
1069
+ snapshot ??= snapshotBranchTrees(
1070
+ projectRoot,
1071
+ branchNames ? input.branches.filter(({ branchName }) => branchNames.has(branchName)) : input.branches,
1072
+ snapshotStorageRoot,
1073
+ input.onSnapshotProgress,
1074
+ branchNames ? "branches" : "project"
1075
+ );
1049
1076
  return snapshot;
1050
1077
  };
1051
1078
  try {
@@ -1097,7 +1124,21 @@ async function ensureProjectWorktrees(input) {
1097
1124
  });
1098
1125
  }
1099
1126
  states.sort((left, right) => left.branchName.localeCompare(right.branchName));
1100
- if (snapshot) persistReconciledProjectAndConsumeSnapshot(snapshot);
1127
+ if (snapshot) {
1128
+ const primaryGitDirectory = import_node_path.default.join(branchPath(projectRoot, input.primaryBranchName), ".git");
1129
+ persistReconciledProjectAndConsumeSnapshot(
1130
+ snapshot,
1131
+ void 0,
1132
+ snapshot.manifest.scope === "branches" ? [
1133
+ ...snapshot.manifest.branches.map(({ branchName }) => branchPath(projectRoot, branchName)),
1134
+ // Branch refs and worktree registrations moved in the shared
1135
+ // administrative directory; objects are fsynced by git itself.
1136
+ import_node_path.default.join(primaryGitDirectory, "refs"),
1137
+ import_node_path.default.join(primaryGitDirectory, "packed-refs"),
1138
+ import_node_path.default.join(primaryGitDirectory, "worktrees")
1139
+ ] : void 0
1140
+ );
1141
+ }
1101
1142
  return states;
1102
1143
  } catch (error) {
1103
1144
  if (!snapshot) throw error;
@@ -1243,6 +1284,53 @@ function createOrRetryLinkedProjectBranch(input) {
1243
1284
  }
1244
1285
  return createLinkedProjectBranch(input);
1245
1286
  }
1287
+ function classifyProjectCheckout(input) {
1288
+ const checkoutPath = branchPath(input.projectRoot, input.branchName);
1289
+ let stat;
1290
+ try {
1291
+ stat = import_node_fs.default.lstatSync(checkoutPath);
1292
+ } catch {
1293
+ return "missing";
1294
+ }
1295
+ if (!stat.isDirectory()) return "not_a_worktree";
1296
+ if (input.branchName === input.primaryBranchName) return gitDirectoryKind(checkoutPath) === "directory" ? "linked" : "not_a_worktree";
1297
+ if (gitDirectoryKind(checkoutPath) !== "file") return "not_a_worktree";
1298
+ const gitDirectory = perWorktreeGitDirectory(checkoutPath);
1299
+ if (!gitDirectory) return "not_a_worktree";
1300
+ let registry;
1301
+ let registered;
1302
+ try {
1303
+ registry = import_node_fs.default.realpathSync(import_node_path.default.join(branchPath(input.projectRoot, input.primaryBranchName), ".git", "worktrees"));
1304
+ registered = import_node_fs.default.realpathSync(gitDirectory);
1305
+ } catch {
1306
+ return "not_a_worktree";
1307
+ }
1308
+ const relative = import_node_path.default.relative(registry, registered);
1309
+ if (!relative || relative === ".." || relative.startsWith(`..${import_node_path.default.sep}`) || import_node_path.default.isAbsolute(relative)) return "not_a_worktree";
1310
+ return "linked";
1311
+ }
1312
+ function inventoryProjectCheckouts(input) {
1313
+ const states = input.branchNames.map((branchName) => ({
1314
+ branchName,
1315
+ checkoutPath: branchPath(input.projectRoot, branchName),
1316
+ state: classifyProjectCheckout({ ...input, branchName })
1317
+ }));
1318
+ const primaryLinked = states.some(({ branchName, state }) => branchName === input.primaryBranchName && state === "linked");
1319
+ const inventory = { linkedBranchNames: [], missingCheckouts: [] };
1320
+ for (const { branchName, checkoutPath, state } of states) {
1321
+ if (primaryLinked && state === "linked") inventory.linkedBranchNames.push(branchName);
1322
+ else inventory.missingCheckouts.push({ branchName, checkoutPath, reason: state === "linked" ? "not_a_worktree" : state });
1323
+ }
1324
+ return inventory;
1325
+ }
1326
+ const PROJECT_REMOVED_CHECKOUTS_DIRECTORY = ".r5d-removed";
1327
+ function moveProjectCheckoutAside(projectRoot, branchName, checkoutPath) {
1328
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
1329
+ const target = import_node_path.default.join(projectRoot, PROJECT_REMOVED_CHECKOUTS_DIRECTORY, ...`${branchName}-${timestamp}`.split("/"));
1330
+ import_node_fs.default.mkdirSync(import_node_path.default.dirname(target), { recursive: true });
1331
+ import_node_fs.default.renameSync(checkoutPath, target);
1332
+ return target;
1333
+ }
1246
1334
  function deleteLinkedProjectBranch(input) {
1247
1335
  (0, import_managed_paths.validateManagedBranchName)(input.primaryBranchName);
1248
1336
  (0, import_managed_paths.validateManagedBranchName)(input.branchName);
@@ -1251,15 +1339,18 @@ function deleteLinkedProjectBranch(input) {
1251
1339
  const checkoutPath = branchPath(input.projectRoot, input.branchName);
1252
1340
  const primaryCommonDir = commonGitDirectory(primaryPath);
1253
1341
  if (!primaryCommonDir) throw new Error("Primary project checkout is unavailable");
1342
+ let movedAsidePath;
1254
1343
  if (import_node_fs.default.existsSync(checkoutPath)) {
1255
- const checkoutCommonDir = commonGitDirectory(checkoutPath);
1344
+ const checkoutCommonDir = import_node_fs.default.lstatSync(checkoutPath).isDirectory() ? commonGitDirectory(checkoutPath) : null;
1256
1345
  if (checkoutCommonDir !== primaryCommonDir) {
1257
- throw new Error(`Project branch path ${input.branchName} is not a linked worktree of the configured project`);
1258
- }
1259
- if (projectWorktreeOperationInProgress(checkoutPath)) {
1260
- throw new Error(`Project branch ${input.branchName} has an in-progress Git operation`);
1346
+ movedAsidePath = moveProjectCheckoutAside(input.projectRoot, input.branchName, checkoutPath);
1347
+ tryGit(primaryPath, ["worktree", "prune"]);
1348
+ } else {
1349
+ if (projectWorktreeOperationInProgress(checkoutPath)) {
1350
+ throw new Error(`Project branch ${input.branchName} has an in-progress Git operation`);
1351
+ }
1352
+ git(primaryPath, ["worktree", "remove", "--force", checkoutPath], `remove linked worktree ${input.branchName}`);
1261
1353
  }
1262
- git(primaryPath, ["worktree", "remove", "--force", checkoutPath], `remove linked worktree ${input.branchName}`);
1263
1354
  } else {
1264
1355
  tryGit(primaryPath, ["worktree", "prune"]);
1265
1356
  }
@@ -1267,7 +1358,7 @@ function deleteLinkedProjectBranch(input) {
1267
1358
  if (tryGit(primaryPath, ["show-ref", "--verify", "--quiet", branchRef])) {
1268
1359
  git(primaryPath, ["branch", "-D", input.branchName], `delete project branch ${input.branchName}`);
1269
1360
  }
1270
- return { branchName: input.branchName };
1361
+ return { branchName: input.branchName, ...movedAsidePath ? { movedAsidePath } : {} };
1271
1362
  }
1272
1363
  function removeProjectWorktrees(input) {
1273
1364
  const projectRoot = import_node_path.default.resolve(input.projectRoot);
@@ -1526,7 +1617,7 @@ const projectWorktreesTestHarness = {
1526
1617
  commandArgs: gitCommandArgs,
1527
1618
  configureRepository,
1528
1619
  createPreparedSnapshot(input) {
1529
- return { root: snapshotBranchTrees(input.projectRoot, input.branches, input.temporaryRoot).root };
1620
+ return { root: snapshotBranchTrees(input.projectRoot, input.branches, input.temporaryRoot, void 0, input.scope).root };
1530
1621
  },
1531
1622
  consumeSnapshot(snapshotRoot) {
1532
1623
  const manifest = readSnapshotManifest(snapshotRoot);
@@ -1542,10 +1633,12 @@ const projectWorktreesTestHarness = {
1542
1633
  };
1543
1634
  // Annotate the CommonJS export names for ESM import in node:
1544
1635
  0 && (module.exports = {
1636
+ PROJECT_REMOVED_CHECKOUTS_DIRECTORY,
1545
1637
  PROJECT_WORKTREE_SNAPSHOT_PREFIX,
1546
1638
  ProjectBranchCreationRollbackIncompleteError,
1547
1639
  applyObservedProjectMirrorHeads,
1548
1640
  assertProjectMirrorHeadsPushed,
1641
+ classifyProjectCheckout,
1549
1642
  computeLocalProjectMirrorRefsTokens,
1550
1643
  createLinkedProjectBranch,
1551
1644
  createOrRetryLinkedProjectBranch,
@@ -1554,6 +1647,7 @@ const projectWorktreesTestHarness = {
1554
1647
  ensureProjectWorktrees,
1555
1648
  fastForwardProjectHeadsFromMirror,
1556
1649
  hasLinkedProjectWorktreeLayout,
1650
+ inventoryProjectCheckouts,
1557
1651
  observeProjectMirrorHeads,
1558
1652
  projectBranchMayExistAfterCreateFailure,
1559
1653
  projectOriginBranchName,
package/dist/mjs/main.mjs CHANGED
@@ -65,6 +65,7 @@ import {
65
65
  deleteLinkedProjectBranch,
66
66
  deleteProjectMirrorBranch,
67
67
  ensureProjectWorktrees,
68
+ inventoryProjectCheckouts,
68
69
  observeProjectMirrorHeads,
69
70
  projectBranchMayExistAfterCreateFailure,
70
71
  projectOriginBranchName,
@@ -3519,6 +3520,7 @@ async function startWorker(options) {
3519
3520
  let terminalReplayTimer;
3520
3521
  let workspaceSyncRequestsInFlight = 0;
3521
3522
  let lastWorkspaceSyncMirrorObservationMs = 0;
3523
+ let lastInventoriedMissingCheckouts;
3522
3524
  let sessionArtifactSyncRequestsInFlight = 0;
3523
3525
  let cliUpdateInProgress = false;
3524
3526
  let reloadAfterClose = false;
@@ -3576,8 +3578,15 @@ async function startWorker(options) {
3576
3578
  const primaryBranchName = primaryProjectBranch(project);
3577
3579
  const connection = projectConnection(project);
3578
3580
  const sourcePath = configuredProjectBranchPath(projectsRoot, project, branchName);
3579
- if (requireLocalBranch || hasProjectWorktree(sourcePath)) {
3580
- deleteLinkedProjectBranch({ projectRoot, primaryBranchName, branchName });
3581
+ const primaryPath = configuredProjectBranchPath(projectsRoot, project, primaryBranchName);
3582
+ if (requireLocalBranch || fs.existsSync(sourcePath) && hasProjectWorktree(primaryPath)) {
3583
+ const deleted = deleteLinkedProjectBranch({ projectRoot, primaryBranchName, branchName });
3584
+ if (deleted.movedAsidePath) {
3585
+ process.stderr.write(
3586
+ `[r5d-worker] set aside ${sourcePath}, which was not a linked worktree of ${project.projectPath}, at ${deleted.movedAsidePath}
3587
+ `
3588
+ );
3589
+ }
3581
3590
  }
3582
3591
  const planSourcePath = path.join(planRoot, project.projectId, ...branchName.split("/"));
3583
3592
  fs.rmSync(planSourcePath, { recursive: true, force: true });
@@ -3650,11 +3659,17 @@ async function startWorker(options) {
3650
3659
  if (branchName === primaryBranchName) {
3651
3660
  throw new Error(`Cannot hide preserve-only project branch ${project.projectId}/${branchName} because it is the active anchor`);
3652
3661
  }
3653
- deleteLinkedProjectBranch({
3662
+ const deleted = deleteLinkedProjectBranch({
3654
3663
  projectRoot: configuredProjectRoot(projectsRoot, project),
3655
3664
  primaryBranchName,
3656
3665
  branchName
3657
3666
  });
3667
+ if (deleted.movedAsidePath) {
3668
+ process.stderr.write(
3669
+ `[r5d-worker] set aside ${branchPath}, which was not a linked worktree of ${project.projectPath}, at ${deleted.movedAsidePath}
3670
+ `
3671
+ );
3672
+ }
3658
3673
  fs.rmSync(path.join(planRoot, project.projectId, ...branchName.split("/")), { recursive: true, force: true });
3659
3674
  readyProjectIds.delete(project.projectId);
3660
3675
  reconciledProjectConfigFingerprints.delete(project.projectId);
@@ -3823,6 +3838,40 @@ async function startWorker(options) {
3823
3838
  });
3824
3839
  };
3825
3840
  const projectBranchMountBusy = (projectId, branchName, branchPath) => !readyProjectIds.has(projectId) || projectBranchMountActivityBusy(projectId, branchName, branchPath);
3841
+ const inventoryConfiguredProjectCheckouts = () => {
3842
+ const inventory = { projects: [], missingCheckouts: [] };
3843
+ for (const project of projectConfigById.values()) {
3844
+ if (project.executionDisabled || !readyProjectIds.has(project.projectId) || project.branches.length === 0) {
3845
+ inventory.projects.push(project);
3846
+ continue;
3847
+ }
3848
+ const checkouts = inventoryProjectCheckouts({
3849
+ projectRoot: configuredProjectRoot(projectsRoot, project),
3850
+ primaryBranchName: primaryProjectBranch(project),
3851
+ branchNames: project.branches.map(({ branchName }) => branchName)
3852
+ });
3853
+ if (checkouts.missingCheckouts.length === 0) {
3854
+ inventory.projects.push(project);
3855
+ continue;
3856
+ }
3857
+ for (const missing of checkouts.missingCheckouts) inventory.missingCheckouts.push({ projectId: project.projectId, ...missing });
3858
+ const linked = new Set(checkouts.linkedBranchNames);
3859
+ inventory.projects.push({ ...project, branches: project.branches.filter(({ branchName }) => linked.has(branchName)) });
3860
+ }
3861
+ inventory.missingCheckouts.sort(
3862
+ (left, right) => left.projectId.localeCompare(right.projectId) || left.branchName.localeCompare(right.branchName)
3863
+ );
3864
+ return inventory;
3865
+ };
3866
+ const reportedMissingCheckouts = (inventory) => inventory.missingCheckouts.map(({ projectId, branchName, reason }) => ({ projectId, branchName, reason }));
3867
+ const logMissingCheckouts = (inventory) => {
3868
+ if (inventory.missingCheckouts.length === 0) return;
3869
+ const described = inventory.missingCheckouts.map(({ checkoutPath, reason }) => `${checkoutPath} (${reason === "missing" ? "directory missing" : "not a linked worktree of the project"})`).join(", ");
3870
+ process.stderr.write(
3871
+ `[r5d-worker] skipping ${inventory.missingCheckouts.length} configured project checkout(s) this cycle: ${described}; delete the branch through the platform or reconnect this worker to recreate the checkout
3872
+ `
3873
+ );
3874
+ };
3826
3875
  const buildWorkspaceMounts = (projects = projectConfigById.values()) => {
3827
3876
  const mounts = [];
3828
3877
  const creatorLocalIncarnations = new Map(
@@ -3948,6 +3997,7 @@ async function startWorker(options) {
3948
3997
  )
3949
3998
  ].sort();
3950
3999
  const ensureConfiguredProjects = async (onProgress) => {
4000
+ const reconciledProjectIds = /* @__PURE__ */ new Set();
3951
4001
  const projects = [...projectConfigById.values()];
3952
4002
  for (const [projectIndex, project] of projects.entries()) {
3953
4003
  onProgress?.({
@@ -4013,6 +4063,7 @@ async function startWorker(options) {
4013
4063
  });
4014
4064
  readyProjectIds.add(project.projectId);
4015
4065
  reconciledProjectConfigFingerprints.set(project.projectId, configFingerprint);
4066
+ reconciledProjectIds.add(project.projectId);
4016
4067
  for (const state of states) {
4017
4068
  pendingCheckouts.delete(projectBranchKey(project.projectId, state.branchName));
4018
4069
  const key = projectBranchKey(project.projectId, state.branchName);
@@ -4050,26 +4101,37 @@ async function startWorker(options) {
4050
4101
  totalItems: projects.length
4051
4102
  });
4052
4103
  }
4104
+ return reconciledProjectIds;
4053
4105
  };
4054
- const collectAheadOfOriginBranches = async () => {
4106
+ const ORIGIN_DIVERGENCE_REFRESH_INTERVAL_MS = 5 * 60 * 1e3;
4107
+ const lastOriginDivergenceFetchMs = /* @__PURE__ */ new Map();
4108
+ const collectAheadOfOriginBranches = async (refetchOriginProjectIds) => {
4055
4109
  const aheadBranches = [];
4110
+ for (const projectId of [...lastOriginDivergenceFetchMs.keys()]) {
4111
+ if (!projectConfigById.has(projectId)) lastOriginDivergenceFetchMs.delete(projectId);
4112
+ }
4056
4113
  for (const project of projectConfigById.values()) {
4057
4114
  if (project.executionDisabled || !readyProjectIds.has(project.projectId) || project.branches.length === 0) continue;
4058
4115
  const connection = projectConnection(project);
4059
4116
  const primaryPath = configuredProjectBranchPath(projectsRoot, project, primaryProjectBranch(project));
4060
- if (!await tryGitAsync(
4061
- [
4062
- ...gitTransportSecurityArgs(connection.originUrl, connection.credentialHelper, connection.originCredentialUsername),
4063
- "fetch",
4064
- "--no-recurse-submodules",
4065
- "--prune",
4066
- "origin",
4067
- "+refs/heads/*:refs/remotes/origin/*"
4068
- ],
4069
- { cwd: primaryPath }
4070
- )) {
4071
- process.stderr.write(`[r5d-worker] could not refresh origin divergence for ${project.projectPath}
4117
+ const lastFetchedAtMs = lastOriginDivergenceFetchMs.get(project.projectId);
4118
+ if (refetchOriginProjectIds.has(project.projectId) || lastFetchedAtMs === void 0 || Date.now() - lastFetchedAtMs >= ORIGIN_DIVERGENCE_REFRESH_INTERVAL_MS) {
4119
+ if (await tryGitAsync(
4120
+ [
4121
+ ...gitTransportSecurityArgs(connection.originUrl, connection.credentialHelper, connection.originCredentialUsername),
4122
+ "fetch",
4123
+ "--no-recurse-submodules",
4124
+ "--prune",
4125
+ "origin",
4126
+ "+refs/heads/*:refs/remotes/origin/*"
4127
+ ],
4128
+ { cwd: primaryPath }
4129
+ )) {
4130
+ lastOriginDivergenceFetchMs.set(project.projectId, Date.now());
4131
+ } else {
4132
+ process.stderr.write(`[r5d-worker] could not refresh origin divergence for ${project.projectPath}
4072
4133
  `);
4134
+ }
4073
4135
  }
4074
4136
  for (const branch of project.branches) {
4075
4137
  const branchPath = configuredProjectBranchPath(projectsRoot, project, branch.branchName);
@@ -4101,9 +4163,9 @@ async function startWorker(options) {
4101
4163
  (left, right) => left.projectId.localeCompare(right.projectId) || left.branchName.localeCompare(right.branchName)
4102
4164
  );
4103
4165
  };
4104
- const captureProjectHeadsForWorkspacePublication = async () => {
4166
+ const captureProjectHeadsForWorkspacePublication = async (projects = projectConfigById.values()) => {
4105
4167
  const heads = /* @__PURE__ */ new Map();
4106
- for (const project of projectConfigById.values()) {
4168
+ for (const project of projects) {
4107
4169
  if (!readyProjectIds.has(project.projectId)) continue;
4108
4170
  for (const branch of project.branches) {
4109
4171
  const branchPath = configuredProjectBranchPath(projectsRoot, project, branch.branchName);
@@ -4113,9 +4175,9 @@ async function startWorker(options) {
4113
4175
  }
4114
4176
  return heads;
4115
4177
  };
4116
- const pushChangedProjectHeads = async (activeMountIds, publishedHead, publicationHeads, expectedMirrorHeads, suppressedMountIds = /* @__PURE__ */ new Set()) => {
4178
+ const pushChangedProjectHeads = async (activeMountIds, publishedHead, publicationHeads, expectedMirrorHeads, suppressedMountIds = /* @__PURE__ */ new Set(), projects = projectConfigById.values()) => {
4117
4179
  const active = new Set(activeMountIds);
4118
- for (const project of projectConfigById.values()) {
4180
+ for (const project of projects) {
4119
4181
  if (!readyProjectIds.has(project.projectId) || !repositoryMirrorWritesAllowed(project)) continue;
4120
4182
  const changed = /* @__PURE__ */ new Map();
4121
4183
  for (const branch of project.branches) {
@@ -4215,7 +4277,7 @@ async function startWorker(options) {
4215
4277
  };
4216
4278
  const observeProjectHeadsBeforeOuterWorkspace = async (input) => {
4217
4279
  const observationStartedAt = Date.now();
4218
- const eligibleProjects = [...projectConfigById.values()].filter(
4280
+ const eligibleProjects = [...input.projects ?? projectConfigById.values()].filter(
4219
4281
  (project) => !project.executionDisabled && readyProjectIds.has(project.projectId) && project.branches.length > 0
4220
4282
  );
4221
4283
  try {
@@ -4325,7 +4387,7 @@ async function startWorker(options) {
4325
4387
  }
4326
4388
  }
4327
4389
  };
4328
- const mapWorkspaceGitResult = async (attemptId, trigger, result) => {
4390
+ const mapWorkspaceGitResult = async (attemptId, trigger, result, missingCheckouts) => {
4329
4391
  const activeProjectBranchPublications = activeProjectBranchPublicationEvidence([...projectConfigById.values()], result.activeMountIds);
4330
4392
  return {
4331
4393
  type: "workspace_sync",
@@ -4343,6 +4405,7 @@ async function startWorker(options) {
4343
4405
  affectedPaths: result.affectedPaths,
4344
4406
  activeMountIds: result.activeMountIds,
4345
4407
  skippedMountIds: result.skippedMountIds,
4408
+ ...missingCheckouts ? { missingCheckouts } : {},
4346
4409
  activeProjectBranchPublications,
4347
4410
  discardedPaths: [],
4348
4411
  localChangesDiscarded: false,
@@ -4370,6 +4433,7 @@ async function startWorker(options) {
4370
4433
  affectedPaths: [],
4371
4434
  discardedPaths: [],
4372
4435
  localChangesDiscarded: false,
4436
+ ...lastInventoriedMissingCheckouts ? { missingCheckouts: lastInventoriedMissingCheckouts } : {},
4373
4437
  error: error instanceof Error ? error.message : String(error)
4374
4438
  };
4375
4439
  };
@@ -4378,7 +4442,11 @@ async function startWorker(options) {
4378
4442
  if (!workspaceRemoteUrl || !workspaceCredentialHelper || !workspaceCredentialUsername || !workspaceGitIdentity) {
4379
4443
  throw new Error("Worker workspace configuration has not been received");
4380
4444
  }
4381
- const configuredMounts = buildWorkspaceMounts();
4445
+ const checkoutInventory = inventoryConfiguredProjectCheckouts();
4446
+ logMissingCheckouts(checkoutInventory);
4447
+ const missingCheckouts = reportedMissingCheckouts(checkoutInventory);
4448
+ lastInventoriedMissingCheckouts = missingCheckouts;
4449
+ const configuredMounts = buildWorkspaceMounts(checkoutInventory.projects);
4382
4450
  if (input.resetToCanonical) {
4383
4451
  workspaceProjectionLedger.reset();
4384
4452
  const resetHasBusyLiveMount = configuredMounts.some(
@@ -4389,7 +4457,8 @@ async function startWorker(options) {
4389
4457
  }
4390
4458
  const observedProjectHeads2 = await observeProjectHeadsBeforeOuterWorkspace({
4391
4459
  allowNonFastForward: true,
4392
- failClosed: true
4460
+ failClosed: true,
4461
+ projects: checkoutInventory.projects
4393
4462
  });
4394
4463
  const reset = await resetWorkspaceGit({
4395
4464
  workspacePath: workspaceShadowRoot,
@@ -4417,6 +4486,7 @@ async function startWorker(options) {
4417
4486
  affectedPaths: reset.discardedPaths,
4418
4487
  activeMountIds: reset.activeMountIds,
4419
4488
  skippedMountIds: reset.skippedMountIds,
4489
+ missingCheckouts,
4420
4490
  discardedPaths: reset.discardedPaths,
4421
4491
  localChangesDiscarded: reset.discardedPaths.length > 0 || reset.startingHead !== reset.remoteHead
4422
4492
  };
@@ -4424,7 +4494,7 @@ async function startWorker(options) {
4424
4494
  const outerRemediation = input.trigger.type === "remediation" || input.trigger.type === "remediation_confirm";
4425
4495
  const mounts = outerRemediation ? workspaceGitMountsForRemediation(configuredMounts) : configuredMounts;
4426
4496
  if (outerRemediation) workspaceProjectionLedger.reset();
4427
- const observedProjectHeads = outerRemediation ? [] : await observeProjectHeadsBeforeOuterWorkspace({ allowNonFastForward: false, failClosed: false });
4497
+ const observedProjectHeads = outerRemediation ? [] : await observeProjectHeadsBeforeOuterWorkspace({ allowNonFastForward: false, failClosed: false, projects: checkoutInventory.projects });
4428
4498
  const expectedMirrorHeads = /* @__PURE__ */ new Map();
4429
4499
  for (const bundle of observedProjectHeads) {
4430
4500
  for (const observation of bundle.observations) {
@@ -4436,7 +4506,7 @@ async function startWorker(options) {
4436
4506
  ({ projectId, observations }) => observations.filter(({ shouldMove }) => shouldMove).map(({ branchName }) => projectMountId(projectId, branchName))
4437
4507
  )
4438
4508
  );
4439
- const publicationHeads = outerRemediation ? /* @__PURE__ */ new Map() : await captureProjectHeadsForWorkspacePublication();
4509
+ const publicationHeads = outerRemediation ? /* @__PURE__ */ new Map() : await captureProjectHeadsForWorkspacePublication(checkoutInventory.projects);
4440
4510
  const result = await synchronizeWorkspaceGit({
4441
4511
  attemptId: input.attemptId,
4442
4512
  workerLabel: label,
@@ -4456,7 +4526,14 @@ async function startWorker(options) {
4456
4526
  advertisedRemoteHead: projectMirrorFetchSkippingEnabled && !outerRemediation ? advertisedWorkspaceHead : void 0,
4457
4527
  assertStillAdmitted: input.assertStillAdmitted,
4458
4528
  afterWorkspacePublished: outerRemediation ? void 0 : async ({ activeMountIds, publishedHead }) => {
4459
- await pushChangedProjectHeads(activeMountIds, publishedHead, publicationHeads, expectedMirrorHeads, inboundMoveMountIds);
4529
+ await pushChangedProjectHeads(
4530
+ activeMountIds,
4531
+ publishedHead,
4532
+ publicationHeads,
4533
+ expectedMirrorHeads,
4534
+ inboundMoveMountIds,
4535
+ checkoutInventory.projects
4536
+ );
4460
4537
  },
4461
4538
  hydrationHooks: workspaceHydrationTransactionHooks
4462
4539
  });
@@ -4475,7 +4552,7 @@ async function startWorker(options) {
4475
4552
  result.localHead ?? result.remoteHead
4476
4553
  );
4477
4554
  }
4478
- const mapped = await mapWorkspaceGitResult(input.attemptId, input.trigger, result);
4555
+ const mapped = await mapWorkspaceGitResult(input.attemptId, input.trigger, result, missingCheckouts);
4479
4556
  if (!outerRemediation) {
4480
4557
  workspaceProjectionLedger.recordCycle({ result: mapped, mounts, projectedMutationTokens: result.projectedMutationTokens });
4481
4558
  }
@@ -5185,7 +5262,7 @@ async function startWorker(options) {
5185
5262
  // checkout initialization from erasing synchronized dirt.
5186
5263
  { ignoreBusy: true, hydrationHooks: workspaceHydrationTransactionHooks }
5187
5264
  );
5188
- await ensureConfiguredProjects((progress) => {
5265
+ const reconciledProjectIds = await ensureConfiguredProjects((progress) => {
5189
5266
  sendLifecycleProgress({ phase: "configuring", operationId: configurationOperationId, ...progress });
5190
5267
  process.stdout.write(
5191
5268
  `[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)`}
@@ -5241,7 +5318,7 @@ async function startWorker(options) {
5241
5318
  const pending = [...pendingCheckouts.values()].sort(
5242
5319
  (left, right) => left.projectId.localeCompare(right.projectId) || left.branchName.localeCompare(right.branchName)
5243
5320
  );
5244
- return { result, pending, aheadOfOriginBranches: await collectAheadOfOriginBranches() };
5321
+ return { result, pending, aheadOfOriginBranches: await collectAheadOfOriginBranches(reconciledProjectIds) };
5245
5322
  });
5246
5323
  } catch (error) {
5247
5324
  if (receiptGeneration !== workspaceConfigurationReceiptGeneration) {
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.103",
3
+ "version": "0.0.105",
4
4
  "type": "module"
5
5
  }
@@ -287,6 +287,8 @@ function normalizeSnapshotManifest(value) {
287
287
  if (record.state !== "building" && record.state !== "prepared" && record.state !== "restoring" && record.state !== "consumed") {
288
288
  throw new Error("Project worktree snapshot manifest has an invalid state");
289
289
  }
290
+ const scope = record.scope === void 0 ? "project" : record.scope;
291
+ if (scope !== "project" && scope !== "branches") throw new Error("Project worktree snapshot manifest has an invalid scope");
290
292
  if (!Number.isSafeInteger(record.ownerProcessId) || record.ownerProcessId <= 0) {
291
293
  throw new Error("Project worktree snapshot manifest has an invalid owner process");
292
294
  }
@@ -322,6 +324,7 @@ function normalizeSnapshotManifest(value) {
322
324
  return {
323
325
  version: PROJECT_WORKTREE_SNAPSHOT_MANIFEST_VERSION,
324
326
  state: record.state,
327
+ scope,
325
328
  ownerProcessId: record.ownerProcessId,
326
329
  ownerSessionId: record.ownerSessionId,
327
330
  projectRoot: record.projectRoot,
@@ -358,7 +361,7 @@ function writeSnapshotManifest(snapshotRoot, manifest) {
358
361
  return readSnapshotManifest(snapshotRoot);
359
362
  }
360
363
  function assertSameSnapshotIdentity(expected, actual) {
361
- if (expected.ownerProcessId !== actual.ownerProcessId || expected.ownerSessionId !== actual.ownerSessionId || expected.projectRoot !== actual.projectRoot || expected.createdAt !== actual.createdAt || JSON.stringify(expected.branches) !== JSON.stringify(actual.branches)) {
364
+ if (expected.scope !== actual.scope || expected.ownerProcessId !== actual.ownerProcessId || expected.ownerSessionId !== actual.ownerSessionId || expected.projectRoot !== actual.projectRoot || expected.createdAt !== actual.createdAt || JSON.stringify(expected.branches) !== JSON.stringify(actual.branches)) {
362
365
  throw new Error("Project worktree snapshot identity changed");
363
366
  }
364
367
  }
@@ -425,7 +428,7 @@ function assertNoOutstandingSnapshotForProject(temporaryRoot, projectRoot) {
425
428
  }
426
429
  }
427
430
  }
428
- function snapshotBranchTrees(projectRoot, branches, temporaryRoot, onProgress) {
431
+ function snapshotBranchTrees(projectRoot, branches, temporaryRoot, onProgress, scope = "project") {
429
432
  const resolvedProjectRoot = path.resolve(projectRoot);
430
433
  const resolvedTemporaryRoot = path.resolve(temporaryRoot ?? os.tmpdir());
431
434
  assertNoOutstandingSnapshotForProject(resolvedTemporaryRoot, resolvedProjectRoot);
@@ -446,6 +449,7 @@ function snapshotBranchTrees(projectRoot, branches, temporaryRoot, onProgress) {
446
449
  let manifest = writeSnapshotManifest(root, {
447
450
  version: PROJECT_WORKTREE_SNAPSHOT_MANIFEST_VERSION,
448
451
  state: "building",
452
+ scope,
449
453
  ownerProcessId: process.pid,
450
454
  ownerSessionId: PROJECT_WORKTREE_SNAPSHOT_OWNER_SESSION_ID,
451
455
  projectRoot: resolvedProjectRoot,
@@ -552,7 +556,7 @@ function restoreProjectWorktreeSnapshot(snapshotRoot, manifest, onProgress) {
552
556
  const presentBranches = current.branches.filter(({ snapshotPresent }) => snapshotPresent);
553
557
  const destinationParent = path.dirname(current.projectRoot);
554
558
  fs.mkdirSync(destinationParent, { recursive: true });
555
- const canMoveWholeSnapshot = presentBranches.length > 0 && (current.state === "restoring" || treesRootStat !== null && treesRootStat.dev === fs.lstatSync(destinationParent).dev);
559
+ const canMoveWholeSnapshot = current.scope === "project" && presentBranches.length > 0 && (current.state === "restoring" || treesRootStat !== null && treesRootStat.dev === fs.lstatSync(destinationParent).dev);
556
560
  if (canMoveWholeSnapshot) {
557
561
  if (current.state === "prepared") current = advanceSnapshotState(snapshotRoot, current, "restoring");
558
562
  onProgress?.({
@@ -643,8 +647,12 @@ function consumeAndRemoveProjectWorktreeSnapshot(snapshotRoot, manifest) {
643
647
  } catch {
644
648
  }
645
649
  }
646
- function persistReconciledProjectAndConsumeSnapshot(snapshot, afterProjectTreeFsync) {
647
- fsyncTree(snapshot.manifest.projectRoot);
650
+ function persistReconciledProjectAndConsumeSnapshot(snapshot, afterProjectTreeFsync, durabilityPaths) {
651
+ if (durabilityPaths) {
652
+ for (const durabilityPath of durabilityPaths) if (fs.existsSync(durabilityPath)) fsyncTree(durabilityPath);
653
+ } else {
654
+ fsyncTree(snapshot.manifest.projectRoot);
655
+ }
648
656
  fsyncManagedProjectRootEntry(snapshot.manifest.projectRoot);
649
657
  afterProjectTreeFsync?.();
650
658
  consumeAndRemoveProjectWorktreeSnapshot(snapshot.root, snapshot.manifest);
@@ -942,35 +950,45 @@ async function reconcileLinkedLayout(input) {
942
950
  await configureRepository({ ...input, primaryPath });
943
951
  await tryGitAsync(primaryPath, ["worktree", "prune"]);
944
952
  await fetchProjectHeads({ ...input, primaryPath });
953
+ const primaryCommonDir = await commonGitDirectoryAsync(primaryPath);
954
+ if (!primaryCommonDir) throw new Error("Primary project checkout is unavailable");
945
955
  const mirrorHeads = /* @__PURE__ */ new Map();
946
- const mutatedBranches = /* @__PURE__ */ new Set();
956
+ const plan = [];
947
957
  for (const branch of input.branches) {
948
958
  const checkoutPath = branchPath(input.projectRoot, branch.branchName);
959
+ const exactMirror = input.exactMirrorBranches?.has(branch.branchName) === true;
949
960
  const resolved = await seedForBranch({
950
961
  ...input,
951
962
  primaryPath,
952
963
  branch,
953
- requireMirrorHead: input.exactMirrorBranches?.has(branch.branchName)
964
+ requireMirrorHead: exactMirror
954
965
  });
955
966
  mirrorHeads.set(branch.branchName, resolved.mirrorHead);
956
- if (branch.branchName === input.primaryBranchName || await commonGitDirectoryAsync(checkoutPath) === await commonGitDirectoryAsync(primaryPath)) {
957
- const currentHead = await gitAsync(checkoutPath, ["rev-parse", "HEAD"], `resolve linked worktree head ${branch.branchName}`);
958
- if (currentHead === resolved.seed) continue;
959
- input.beforeDestructiveMutation();
960
- mutatedBranches.add(branch.branchName);
961
- await preserveHead(checkoutPath);
962
- await gitAsync(checkoutPath, ["reset", "--hard", resolved.seed], `anchor linked worktree ${branch.branchName}`);
967
+ const linked = branch.branchName === input.primaryBranchName || gitDirectoryKind(checkoutPath) !== null && await commonGitDirectoryAsync(checkoutPath) === primaryCommonDir;
968
+ if (!linked) {
969
+ plan.push({ branchName: branch.branchName, checkoutPath, seed: resolved.seed, action: "recreate" });
963
970
  continue;
964
971
  }
965
- input.beforeDestructiveMutation();
966
- mutatedBranches.add(branch.branchName);
967
- fs.rmSync(checkoutPath, { recursive: true, force: true });
968
- await gitAsync(primaryPath, ["branch", "-f", branch.branchName, resolved.seed], `anchor project branch ${branch.branchName}`);
969
- fs.mkdirSync(path.dirname(checkoutPath), { recursive: true });
972
+ const currentHead = await gitAsync(checkoutPath, ["rev-parse", "HEAD"], `resolve linked worktree head ${branch.branchName}`);
973
+ const keep = currentHead === resolved.seed || !exactMirror && await tryGitAsync(checkoutPath, ["merge-base", "--is-ancestor", resolved.seed, currentHead]);
974
+ plan.push({ branchName: branch.branchName, checkoutPath, seed: resolved.seed, action: keep ? "keep" : "reset" });
975
+ }
976
+ const mutatedBranches = new Set(plan.filter(({ action }) => action !== "keep").map(({ branchName }) => branchName));
977
+ if (mutatedBranches.size > 0) input.beforeDestructiveMutation(mutatedBranches);
978
+ for (const entry of plan) {
979
+ if (entry.action === "keep") continue;
980
+ if (entry.action === "reset") {
981
+ await preserveHead(entry.checkoutPath);
982
+ await gitAsync(entry.checkoutPath, ["reset", "--hard", entry.seed], `anchor linked worktree ${entry.branchName}`);
983
+ continue;
984
+ }
985
+ fs.rmSync(entry.checkoutPath, { recursive: true, force: true });
986
+ await gitAsync(primaryPath, ["branch", "-f", entry.branchName, entry.seed], `anchor project branch ${entry.branchName}`);
987
+ fs.mkdirSync(path.dirname(entry.checkoutPath), { recursive: true });
970
988
  await gitAsync(
971
989
  primaryPath,
972
- ["worktree", "add", "--force", checkoutPath, branch.branchName],
973
- `create linked worktree ${branch.branchName}`
990
+ ["worktree", "add", "--force", entry.checkoutPath, entry.branchName],
991
+ `create linked worktree ${entry.branchName}`
974
992
  );
975
993
  }
976
994
  return { mirrorHeads, mutatedBranches };
@@ -997,8 +1015,14 @@ async function ensureProjectWorktrees(input) {
997
1015
  const snapshotStorageRoot = path.resolve(input.snapshotRoot ?? os.tmpdir());
998
1016
  assertNoOutstandingSnapshotForProject(snapshotStorageRoot, projectRoot);
999
1017
  let snapshot;
1000
- const ensureSnapshot = () => {
1001
- snapshot ??= snapshotBranchTrees(projectRoot, input.branches, snapshotStorageRoot, input.onSnapshotProgress);
1018
+ const ensureSnapshot = (branchNames) => {
1019
+ snapshot ??= snapshotBranchTrees(
1020
+ projectRoot,
1021
+ branchNames ? input.branches.filter(({ branchName }) => branchNames.has(branchName)) : input.branches,
1022
+ snapshotStorageRoot,
1023
+ input.onSnapshotProgress,
1024
+ branchNames ? "branches" : "project"
1025
+ );
1002
1026
  return snapshot;
1003
1027
  };
1004
1028
  try {
@@ -1050,7 +1074,21 @@ async function ensureProjectWorktrees(input) {
1050
1074
  });
1051
1075
  }
1052
1076
  states.sort((left, right) => left.branchName.localeCompare(right.branchName));
1053
- if (snapshot) persistReconciledProjectAndConsumeSnapshot(snapshot);
1077
+ if (snapshot) {
1078
+ const primaryGitDirectory = path.join(branchPath(projectRoot, input.primaryBranchName), ".git");
1079
+ persistReconciledProjectAndConsumeSnapshot(
1080
+ snapshot,
1081
+ void 0,
1082
+ snapshot.manifest.scope === "branches" ? [
1083
+ ...snapshot.manifest.branches.map(({ branchName }) => branchPath(projectRoot, branchName)),
1084
+ // Branch refs and worktree registrations moved in the shared
1085
+ // administrative directory; objects are fsynced by git itself.
1086
+ path.join(primaryGitDirectory, "refs"),
1087
+ path.join(primaryGitDirectory, "packed-refs"),
1088
+ path.join(primaryGitDirectory, "worktrees")
1089
+ ] : void 0
1090
+ );
1091
+ }
1054
1092
  return states;
1055
1093
  } catch (error) {
1056
1094
  if (!snapshot) throw error;
@@ -1196,6 +1234,53 @@ function createOrRetryLinkedProjectBranch(input) {
1196
1234
  }
1197
1235
  return createLinkedProjectBranch(input);
1198
1236
  }
1237
+ function classifyProjectCheckout(input) {
1238
+ const checkoutPath = branchPath(input.projectRoot, input.branchName);
1239
+ let stat;
1240
+ try {
1241
+ stat = fs.lstatSync(checkoutPath);
1242
+ } catch {
1243
+ return "missing";
1244
+ }
1245
+ if (!stat.isDirectory()) return "not_a_worktree";
1246
+ if (input.branchName === input.primaryBranchName) return gitDirectoryKind(checkoutPath) === "directory" ? "linked" : "not_a_worktree";
1247
+ if (gitDirectoryKind(checkoutPath) !== "file") return "not_a_worktree";
1248
+ const gitDirectory = perWorktreeGitDirectory(checkoutPath);
1249
+ if (!gitDirectory) return "not_a_worktree";
1250
+ let registry;
1251
+ let registered;
1252
+ try {
1253
+ registry = fs.realpathSync(path.join(branchPath(input.projectRoot, input.primaryBranchName), ".git", "worktrees"));
1254
+ registered = fs.realpathSync(gitDirectory);
1255
+ } catch {
1256
+ return "not_a_worktree";
1257
+ }
1258
+ const relative = path.relative(registry, registered);
1259
+ if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return "not_a_worktree";
1260
+ return "linked";
1261
+ }
1262
+ function inventoryProjectCheckouts(input) {
1263
+ const states = input.branchNames.map((branchName) => ({
1264
+ branchName,
1265
+ checkoutPath: branchPath(input.projectRoot, branchName),
1266
+ state: classifyProjectCheckout({ ...input, branchName })
1267
+ }));
1268
+ const primaryLinked = states.some(({ branchName, state }) => branchName === input.primaryBranchName && state === "linked");
1269
+ const inventory = { linkedBranchNames: [], missingCheckouts: [] };
1270
+ for (const { branchName, checkoutPath, state } of states) {
1271
+ if (primaryLinked && state === "linked") inventory.linkedBranchNames.push(branchName);
1272
+ else inventory.missingCheckouts.push({ branchName, checkoutPath, reason: state === "linked" ? "not_a_worktree" : state });
1273
+ }
1274
+ return inventory;
1275
+ }
1276
+ const PROJECT_REMOVED_CHECKOUTS_DIRECTORY = ".r5d-removed";
1277
+ function moveProjectCheckoutAside(projectRoot, branchName, checkoutPath) {
1278
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
1279
+ const target = path.join(projectRoot, PROJECT_REMOVED_CHECKOUTS_DIRECTORY, ...`${branchName}-${timestamp}`.split("/"));
1280
+ fs.mkdirSync(path.dirname(target), { recursive: true });
1281
+ fs.renameSync(checkoutPath, target);
1282
+ return target;
1283
+ }
1199
1284
  function deleteLinkedProjectBranch(input) {
1200
1285
  validateManagedBranchName(input.primaryBranchName);
1201
1286
  validateManagedBranchName(input.branchName);
@@ -1204,15 +1289,18 @@ function deleteLinkedProjectBranch(input) {
1204
1289
  const checkoutPath = branchPath(input.projectRoot, input.branchName);
1205
1290
  const primaryCommonDir = commonGitDirectory(primaryPath);
1206
1291
  if (!primaryCommonDir) throw new Error("Primary project checkout is unavailable");
1292
+ let movedAsidePath;
1207
1293
  if (fs.existsSync(checkoutPath)) {
1208
- const checkoutCommonDir = commonGitDirectory(checkoutPath);
1294
+ const checkoutCommonDir = fs.lstatSync(checkoutPath).isDirectory() ? commonGitDirectory(checkoutPath) : null;
1209
1295
  if (checkoutCommonDir !== primaryCommonDir) {
1210
- throw new Error(`Project branch path ${input.branchName} is not a linked worktree of the configured project`);
1211
- }
1212
- if (projectWorktreeOperationInProgress(checkoutPath)) {
1213
- throw new Error(`Project branch ${input.branchName} has an in-progress Git operation`);
1296
+ movedAsidePath = moveProjectCheckoutAside(input.projectRoot, input.branchName, checkoutPath);
1297
+ tryGit(primaryPath, ["worktree", "prune"]);
1298
+ } else {
1299
+ if (projectWorktreeOperationInProgress(checkoutPath)) {
1300
+ throw new Error(`Project branch ${input.branchName} has an in-progress Git operation`);
1301
+ }
1302
+ git(primaryPath, ["worktree", "remove", "--force", checkoutPath], `remove linked worktree ${input.branchName}`);
1214
1303
  }
1215
- git(primaryPath, ["worktree", "remove", "--force", checkoutPath], `remove linked worktree ${input.branchName}`);
1216
1304
  } else {
1217
1305
  tryGit(primaryPath, ["worktree", "prune"]);
1218
1306
  }
@@ -1220,7 +1308,7 @@ function deleteLinkedProjectBranch(input) {
1220
1308
  if (tryGit(primaryPath, ["show-ref", "--verify", "--quiet", branchRef])) {
1221
1309
  git(primaryPath, ["branch", "-D", input.branchName], `delete project branch ${input.branchName}`);
1222
1310
  }
1223
- return { branchName: input.branchName };
1311
+ return { branchName: input.branchName, ...movedAsidePath ? { movedAsidePath } : {} };
1224
1312
  }
1225
1313
  function removeProjectWorktrees(input) {
1226
1314
  const projectRoot = path.resolve(input.projectRoot);
@@ -1479,7 +1567,7 @@ const projectWorktreesTestHarness = {
1479
1567
  commandArgs: gitCommandArgs,
1480
1568
  configureRepository,
1481
1569
  createPreparedSnapshot(input) {
1482
- return { root: snapshotBranchTrees(input.projectRoot, input.branches, input.temporaryRoot).root };
1570
+ return { root: snapshotBranchTrees(input.projectRoot, input.branches, input.temporaryRoot, void 0, input.scope).root };
1483
1571
  },
1484
1572
  consumeSnapshot(snapshotRoot) {
1485
1573
  const manifest = readSnapshotManifest(snapshotRoot);
@@ -1494,10 +1582,12 @@ const projectWorktreesTestHarness = {
1494
1582
  }
1495
1583
  };
1496
1584
  export {
1585
+ PROJECT_REMOVED_CHECKOUTS_DIRECTORY,
1497
1586
  PROJECT_WORKTREE_SNAPSHOT_PREFIX,
1498
1587
  ProjectBranchCreationRollbackIncompleteError,
1499
1588
  applyObservedProjectMirrorHeads,
1500
1589
  assertProjectMirrorHeadsPushed,
1590
+ classifyProjectCheckout,
1501
1591
  computeLocalProjectMirrorRefsTokens,
1502
1592
  createLinkedProjectBranch,
1503
1593
  createOrRetryLinkedProjectBranch,
@@ -1506,6 +1596,7 @@ export {
1506
1596
  ensureProjectWorktrees,
1507
1597
  fastForwardProjectHeadsFromMirror,
1508
1598
  hasLinkedProjectWorktreeLayout,
1599
+ inventoryProjectCheckouts,
1509
1600
  observeProjectMirrorHeads,
1510
1601
  projectBranchMayExistAfterCreateFailure,
1511
1602
  projectOriginBranchName,
@@ -93,6 +93,12 @@ type WorkspaceSyncResult = {
93
93
  affectedPaths: string[];
94
94
  activeMountIds?: string[];
95
95
  skippedMountIds?: string[];
96
+ /** Configured checkouts this cycle excluded because their directory is absent or is not a linked worktree of the project. */
97
+ missingCheckouts?: Array<{
98
+ projectId: string;
99
+ branchName: string;
100
+ reason: "missing" | "not_a_worktree";
101
+ }>;
96
102
  activeProjectBranchPublications?: Array<{
97
103
  branchId: string;
98
104
  projectId: string;
@@ -1,9 +1,17 @@
1
1
  import { type ProjectMirrorMount, type ProjectMirrorRefsTokens } from "./project-mirror-refs-token";
2
2
  export declare const PROJECT_WORKTREE_SNAPSHOT_PREFIX = "r5d-project-worktrees-";
3
3
  declare const PROJECT_WORKTREE_SNAPSHOT_MANIFEST_VERSION: 1;
4
+ /**
5
+ * `project` snapshots cover every configured branch and restore by replacing
6
+ * the whole project root. `branches` snapshots cover only the checkouts one
7
+ * reconciliation rewrites and restore those paths individually, so a rollback
8
+ * never touches a checkout the transaction did not.
9
+ */
10
+ type ProjectWorktreeSnapshotScope = "project" | "branches";
4
11
  type ProjectWorktreeSnapshotManifest = {
5
12
  version: typeof PROJECT_WORKTREE_SNAPSHOT_MANIFEST_VERSION;
6
13
  state: "building" | "prepared" | "restoring" | "consumed";
14
+ scope: ProjectWorktreeSnapshotScope;
7
15
  ownerProcessId: number;
8
16
  ownerSessionId: string;
9
17
  projectRoot: string;
@@ -157,12 +165,50 @@ export declare function createOrRetryLinkedProjectBranch(input: {
157
165
  branchPath: string;
158
166
  baseCommitHash: string;
159
167
  };
168
+ export type ProjectCheckoutState = "linked" | "missing" | "not_a_worktree";
169
+ export type ProjectCheckoutInventory = {
170
+ /** Branches whose checkout is this project's primary clone or one of its linked worktrees. */
171
+ linkedBranchNames: string[];
172
+ missingCheckouts: Array<{
173
+ branchName: string;
174
+ checkoutPath: string;
175
+ reason: Exclude<ProjectCheckoutState, "linked">;
176
+ }>;
177
+ };
178
+ /**
179
+ * Classify one configured branch checkout without spawning git, cheap enough
180
+ * to run for every configured branch on every sync cycle. The primary
181
+ * checkout is `linked` when its `.git` is a directory; another branch is
182
+ * `linked` when its `.git` pointer file resolves into that primary clone's
183
+ * `.git/worktrees` registry and the registered directory still exists.
184
+ * `missing` means the checkout path is absent. Everything else (a hydrated
185
+ * directory that never became a checkout, a foreign clone, a pointer to a
186
+ * pruned registration) is `not_a_worktree`.
187
+ */
188
+ export declare function classifyProjectCheckout(input: {
189
+ projectRoot: string;
190
+ primaryBranchName: string;
191
+ branchName: string;
192
+ }): ProjectCheckoutState;
193
+ /**
194
+ * Partition a ready project's configured branches into checkouts a sync cycle
195
+ * may use and checkouts it must skip. A primary clone that is not linked takes
196
+ * every branch with it: nothing can be observed or published through it.
197
+ */
198
+ export declare function inventoryProjectCheckouts(input: {
199
+ projectRoot: string;
200
+ primaryBranchName: string;
201
+ branchNames: readonly string[];
202
+ }): ProjectCheckoutInventory;
203
+ export declare const PROJECT_REMOVED_CHECKOUTS_DIRECTORY = ".r5d-removed";
160
204
  export declare function deleteLinkedProjectBranch(input: {
161
205
  projectRoot: string;
162
206
  primaryBranchName: string;
163
207
  branchName: string;
164
208
  }): {
165
209
  branchName: string;
210
+ /** Where a path that was not this project's linked worktree was set aside instead of being deleted. */
211
+ movedAsidePath?: string;
166
212
  };
167
213
  export declare function removeProjectWorktrees(input: {
168
214
  projectRoot: string;
@@ -290,6 +336,7 @@ export declare const projectWorktreesTestHarness: {
290
336
  projectRoot: string;
291
337
  branches: readonly ProjectWorktreeBranch[];
292
338
  temporaryRoot: string;
339
+ scope?: ProjectWorktreeSnapshotScope;
293
340
  }): {
294
341
  root: string;
295
342
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.103",
3
+ "version": "0.0.105",
4
4
  "type": "module",
5
5
  "main": "./dist/cjs/main.cjs",
6
6
  "module": "./dist/mjs/main.mjs",