@ricsam/r5d-worker 0.0.136 → 0.0.137

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
@@ -4301,6 +4301,8 @@ async function startWorker(options, projectRuntime = {
4301
4301
  });
4302
4302
  };
4303
4303
  const projectBranchMountBusy = (projectId, branchName, branchPath) => !readyProjectIds.has(projectId) || projectBranchMountActivityBusy(projectId, branchName, branchPath);
4304
+ const projectBranchMountFenced = (projectId, branchName, branchPath) => !readyProjectIds.has(projectId) || projectConfigById.get(projectId)?.executionDisabled === true || hasProjectWorktree(branchPath) && (0, import_project_worktrees.projectWorktreeOperationInProgress)(branchPath) || canonicalWorkspaceMutationIsActive(activeWorkspaceMutationTargets());
4305
+ const projectBranchMountProcessLive = (projectId, branchName) => (0, import_workspace_automatic_sync_policy.projectBranchHasActiveWorkspaceTarget)(projectId, branchName, activeWorkspaceMutationTargets());
4304
4306
  const inventoryConfiguredProjectCheckouts = () => {
4305
4307
  const inventory = { projects: [], missingCheckouts: [] };
4306
4308
  for (const project of projectConfigById.values()) {
@@ -4386,7 +4388,11 @@ async function startWorker(options, projectRuntime = {
4386
4388
  ].filter((id) => typeof id === "string")
4387
4389
  )
4388
4390
  ].join(", ") || "no live declared holder",
4389
- busy: () => projectBranchMountBusy(project.projectId, branch.branchName, branchPath),
4391
+ // A running job no longer pins its checkout: merge hydration folds
4392
+ // inbound changes into it. The plan mount below keeps the old fence
4393
+ // because its all-mode hydration is a plain mirror.
4394
+ busy: () => projectBranchMountFenced(project.projectId, branch.branchName, branchPath),
4395
+ processLive: () => projectBranchMountProcessLive(project.projectId, branch.branchName),
4390
4396
  mutationToken: () => projectWorkspaceMutationToken(project.projectId, branch.branchName),
4391
4397
  busyForRecovery: () => projectBranchMountExclusiveSyncBusy(project.projectId, branch.branchName, branchPath)
4392
4398
  });
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.136",
3
+ "version": "0.0.137",
4
4
  "type": "commonjs"
5
5
  }
@@ -36,6 +36,7 @@ __export(workspace_git_sync_exports, {
36
36
  WORKSPACE_GIT_HYDRATION_RECOVERIES: () => WORKSPACE_GIT_HYDRATION_RECOVERIES,
37
37
  WORKSPACE_GIT_HYDRATION_TRANSACTION: () => WORKSPACE_GIT_HYDRATION_TRANSACTION,
38
38
  WORKSPACE_MERGE_HYDRATION_SWITCH: () => WORKSPACE_MERGE_HYDRATION_SWITCH,
39
+ WORKSPACE_PROCESS_BUSY_FENCE_SWITCH: () => WORKSPACE_PROCESS_BUSY_FENCE_SWITCH,
39
40
  WorkspaceHydrationRecoveryRequiredError: () => WorkspaceHydrationRecoveryRequiredError,
40
41
  WorkspaceRemediationAncestryError: () => WorkspaceRemediationAncestryError,
41
42
  configureExistingWorkspaceGitForRemediation: () => configureExistingWorkspaceGitForRemediation,
@@ -86,12 +87,20 @@ const WORKSPACE_MERGE_HYDRATION_SWITCH = "R5D_MERGE_HYDRATION";
86
87
  function workspaceMergeHydrationEnabled() {
87
88
  return process.env[WORKSPACE_MERGE_HYDRATION_SWITCH] !== "0";
88
89
  }
90
+ const WORKSPACE_PROCESS_BUSY_FENCE_SWITCH = "R5D_PROCESS_BUSY_FENCE";
91
+ function workspaceProcessBusyFenceEnabled() {
92
+ return process.env[WORKSPACE_PROCESS_BUSY_FENCE_SWITCH] === "1";
93
+ }
94
+ function mountBusy(mount) {
95
+ if (mount.busy !== void 0 && mount.busy()) return true;
96
+ return workspaceProcessBusyFenceEnabled() && mount.processLive?.() === true;
97
+ }
89
98
  const hydrationPreBlobLedgers = /* @__PURE__ */ new Map();
90
99
  function hydrationPreBlobLedger(workspacePath) {
91
100
  const key = import_node_path.default.resolve(workspacePath);
92
101
  let ledger = hydrationPreBlobLedgers.get(key);
93
102
  if (!ledger) {
94
- ledger = new import_workspace_hydration_ledger.WorkspaceHydrationPreBlobLedger();
103
+ ledger = new import_workspace_hydration_ledger.WorkspaceHydrationLedger();
95
104
  hydrationPreBlobLedgers.set(key, ledger);
96
105
  }
97
106
  return ledger;
@@ -531,7 +540,7 @@ function workspaceGitHydrationHolds(workspacePath, mounts, tolerateIdleRecordedB
531
540
  if (head === null) return true;
532
541
  if (!receipt) return false;
533
542
  return mounts ? configuredWorkspaceHydrationBasisMounts(resolvedWorkspacePath, mounts).every(
534
- (mount) => workspaceHydrationReceiptCoversMount(receipt, head, mount) || Boolean((tolerateIdleRecordedBasis || mount.busy?.()) && workspaceHydrationReceiptMountBasisHead(receipt, mount))
543
+ (mount) => workspaceHydrationReceiptCoversMount(receipt, head, mount) || Boolean((tolerateIdleRecordedBasis || mountBusy(mount)) && workspaceHydrationReceiptMountBasisHead(receipt, mount))
535
544
  ) : Boolean(receipt && receipt.mounts.every((mount) => tolerateIdleRecordedBasis || mount.head === head));
536
545
  }
537
546
  const WORKSPACE_GIT_BASIS_REF_PREFIX = "refs/r5d/workspace-basis/";
@@ -1492,6 +1501,12 @@ function resolveGitObjectsSync(workspacePath, names) {
1492
1501
  });
1493
1502
  return resolved;
1494
1503
  }
1504
+ function storeWorkspaceBlobs(workspacePath, blobs) {
1505
+ for (const [objectId, content] of blobs) {
1506
+ const written = gitOutputSync(workspacePath, ["hash-object", "-w", "-t", "blob", "--no-filters", "--stdin"], "store hydration ledger blob", content).toString().trim();
1507
+ if (written !== objectId) throw new Error(`Hydration ledger blob ${objectId} was stored as ${written}`);
1508
+ }
1509
+ }
1495
1510
  function readWorkspaceBlobs(workspacePath, objectIds) {
1496
1511
  const blobs = /* @__PURE__ */ new Map();
1497
1512
  const unique = [...new Set(objectIds)];
@@ -1983,7 +1998,7 @@ function activeMounts(mounts) {
1983
1998
  const tombstones = [];
1984
1999
  const skipped = [];
1985
2000
  for (const mount of mounts) {
1986
- if (mount.busy?.()) skipped.push(mount);
2001
+ if (mountBusy(mount)) skipped.push(mount);
1987
2002
  else if (import_node_fs.default.existsSync(mount.sourcePath)) active.push(mount);
1988
2003
  else if (mount.deleteWhenSourceMissing) tombstones.push(mount);
1989
2004
  else skipped.push(mount);
@@ -2121,9 +2136,9 @@ function hydrateWorkspaceGitMountsRaw(workspacePath, mounts, options = {}) {
2121
2136
  const skippedMountIds = [];
2122
2137
  const mergeSkips = [];
2123
2138
  const rollbackScopes = /* @__PURE__ */ new Map();
2124
- const preBlobs = {};
2139
+ const ledgerEntries = {};
2125
2140
  for (const mount of mounts) {
2126
- if (!options.ignoreBusy && mount.busy?.()) {
2141
+ if (!options.ignoreBusy && mountBusy(mount)) {
2127
2142
  skippedMountIds.push(mount.id);
2128
2143
  continue;
2129
2144
  }
@@ -2170,7 +2185,20 @@ function hydrateWorkspaceGitMountsRaw(workspacePath, mounts, options = {}) {
2170
2185
  continue;
2171
2186
  }
2172
2187
  hydratedMountIds.push(mount.id);
2173
- preBlobs[mount.id] = Object.fromEntries(mergePlan.decision.preBlobs);
2188
+ storeWorkspaceBlobs(workspacePath, mergePlan.decision.blobsToStore);
2189
+ const left = /* @__PURE__ */ new Map();
2190
+ for (const entry of journal.entries) left.set(entry.relativePath, entry.after);
2191
+ const entries = {};
2192
+ for (const [relativePath, preBlob] of mergePlan.decision.preBlobs) {
2193
+ const postBlob = mergePlan.decision.postBlobs.get(relativePath) ?? null;
2194
+ let after = left.get(relativePath);
2195
+ if (after === void 0) {
2196
+ const current = (0, import_working_tree_mirror.inspectWorkingTreePath)(mount.sourcePath, relativePath);
2197
+ after = current.kind === "entry" ? current.stat : null;
2198
+ }
2199
+ entries[relativePath] = { preBlob, postBlob, stat: after && after.isFile() ? (0, import_working_tree_mirror.projectedFileEntry)(after) : null };
2200
+ }
2201
+ ledgerEntries[mount.id] = entries;
2174
2202
  continue;
2175
2203
  }
2176
2204
  const sourceRoot = workspaceMountOuterPath(workspacePath, mount);
@@ -2218,7 +2246,7 @@ function hydrateWorkspaceGitMountsRaw(workspacePath, mounts, options = {}) {
2218
2246
  });
2219
2247
  hydratedMountIds.push(mount.id);
2220
2248
  }
2221
- return { hydratedMountIds: hydratedMountIds.sort(), skippedMountIds: skippedMountIds.sort(), mergeSkips, rollbackScopes, preBlobs };
2249
+ return { hydratedMountIds: hydratedMountIds.sort(), skippedMountIds: skippedMountIds.sort(), mergeSkips, rollbackScopes, ledgerEntries };
2222
2250
  }
2223
2251
  async function selectMountsWithUnchangedOuterSubtree(workspacePath, receipt, head, mounts) {
2224
2252
  const candidates = mounts.flatMap((mount) => {
@@ -2259,7 +2287,7 @@ async function hydrateWorkspaceGitMountsTransactionally(input) {
2259
2287
  const targetHead = input.recordCurrentHead ? await revParseAsync(workspacePath, "HEAD") : null;
2260
2288
  const receiptBeforeTransaction = targetHead ? await readHydratedWorkspaceReceiptAsync(workspacePath) : null;
2261
2289
  const computeSelection = () => {
2262
- const busyMountIds = new Set(input.ignoreBusy ? [] : candidateMounts.filter((mount) => mount.busy?.()).map(({ id }) => id));
2290
+ const busyMountIds = new Set(input.ignoreBusy ? [] : candidateMounts.filter((mount) => mountBusy(mount)).map(({ id }) => id));
2263
2291
  const requestedIds = new Set(input.mounts.map(({ id }) => id));
2264
2292
  const requiredIds = new Set(requiredMounts.map(({ id }) => id));
2265
2293
  const hydrationMounts = input.mounts.filter(({ id }) => !busyMountIds.has(id));
@@ -2278,7 +2306,7 @@ async function hydrateWorkspaceGitMountsTransactionally(input) {
2278
2306
  };
2279
2307
  const noopCandidate = computeSelection();
2280
2308
  if (noopCandidate.hydrationMounts.length === 0 && noopCandidate.advancedDurabilityMounts.length === 0 && noopCandidate.targetReceipt && lstatIfExists(hydrationTransactionPath(workspacePath)) === null && workspaceHydrationReceiptsEqual(receiptBeforeTransaction, noopCandidate.targetReceipt) && await hydratedWorkspaceReceiptFileIsV3(workspacePath) && await workspaceBasisRefsAreCurrentAsync(workspacePath, noopCandidate.targetReceipt)) {
2281
- return { hydratedMountIds: [], skippedMountIds: noopCandidate.skippedMountIds, mergeSkips: [] };
2309
+ return { hydratedMountIds: [], skippedMountIds: noopCandidate.skippedMountIds, mergeSkips: [], hydratedEntryPaths: {} };
2282
2310
  }
2283
2311
  const hooks = input.hydrationHooks;
2284
2312
  const context = {
@@ -2298,6 +2326,7 @@ async function hydrateWorkspaceGitMountsTransactionally(input) {
2298
2326
  const basis = input.mergeBases?.get(mount.id);
2299
2327
  if (basis) mergeBases[mount.id] = basis;
2300
2328
  }
2329
+ const liveBefore = new Set(hydrationMounts.filter((mount) => mergeBases[mount.id] && mount.processLive?.()).map(({ id }) => id));
2301
2330
  const hydration = await (0, import_workspace_filesystem_executor.workspaceFilesystemExecutor)().run("hydration_transaction", {
2302
2331
  workspacePath,
2303
2332
  hydrationMounts: hydrationMounts.map(workspaceGitMountData),
@@ -2306,16 +2335,27 @@ async function hydrateWorkspaceGitMountsTransactionally(input) {
2306
2335
  ...Object.keys(mergeBases).length > 0 ? { mergeBases } : {}
2307
2336
  });
2308
2337
  const ledger = hydrationPreBlobLedger(workspacePath);
2338
+ const hydratedEntryPaths = {};
2309
2339
  for (const mount of hydrationMounts) {
2310
2340
  if (!hydration.hydratedMountIds.includes(mount.id)) continue;
2311
- const preBlobs = mergeBases[mount.id] ? hydration.hydratedPreBlobs[mount.id] : void 0;
2312
- if (preBlobs) ledger.record(mount, preBlobs);
2313
- else ledger.clearMount(mount);
2341
+ const entries = mergeBases[mount.id] ? hydration.hydratedEntries[mount.id] : void 0;
2342
+ if (!entries) {
2343
+ ledger.clearMount(mount);
2344
+ continue;
2345
+ }
2346
+ const processLiveAcrossHydration = liveBefore.has(mount.id) || mount.processLive?.() === true;
2347
+ hydratedEntryPaths[mount.id] = Object.keys(entries).sort();
2348
+ ledger.record(
2349
+ mount,
2350
+ Object.fromEntries(Object.entries(entries).map(([relativePath, entry]) => [relativePath, { ...entry, processLiveAcrossHydration }])),
2351
+ { replacePreBlobFor: input.ledgerMergedPaths?.get(mount.id) ?? [] }
2352
+ );
2314
2353
  }
2315
2354
  return {
2316
2355
  hydratedMountIds: hydration.hydratedMountIds,
2317
2356
  skippedMountIds: [.../* @__PURE__ */ new Set([...selection.skippedMountIds, ...hydration.skippedMounts.map(({ mountId }) => mountId)])].sort(),
2318
- mergeSkips: hydration.skippedMounts
2357
+ mergeSkips: hydration.skippedMounts,
2358
+ hydratedEntryPaths
2319
2359
  };
2320
2360
  } finally {
2321
2361
  releaseHold?.();
@@ -2380,7 +2420,7 @@ function runHydrationTransactionJob(input) {
2380
2420
  markHydrationTransactionDurable(workspacePath);
2381
2421
  if (targetReceipt) updateHydratedWorkspaceReceipt(workspacePath, targetReceipt);
2382
2422
  removeHydrationTransaction(workspacePath);
2383
- return { hydratedMountIds: hydration.hydratedMountIds, skippedMounts, hydratedPreBlobs: hydration.preBlobs };
2423
+ return { hydratedMountIds: hydration.hydratedMountIds, skippedMounts, hydratedEntries: hydration.ledgerEntries };
2384
2424
  } catch (error) {
2385
2425
  const transactionPath = hydrationTransactionPath(workspacePath);
2386
2426
  if (lstatIfExists(transactionPath) && hydrationTransactionCommitted(transactionPath)) {
@@ -2434,7 +2474,7 @@ async function hydrateWorkspaceGitMounts(workspacePath, mounts, options = {}) {
2434
2474
  const resolvedWorkspacePath = import_node_path.default.resolve(workspacePath);
2435
2475
  const gitDirectory = import_node_path.default.join(resolvedWorkspacePath, ".git");
2436
2476
  if (!import_node_fs.default.existsSync(gitDirectory)) {
2437
- const skippedMountIds = options.ignoreBusy ? [] : mounts.filter((mount) => mount.busy?.()).map(({ id }) => id).sort();
2477
+ const skippedMountIds = options.ignoreBusy ? [] : mounts.filter((mount) => mountBusy(mount)).map(({ id }) => id).sort();
2438
2478
  const skipped = new Set(skippedMountIds);
2439
2479
  const selected = mounts.filter(({ id }) => !skipped.has(id));
2440
2480
  const releaseHold = options.hydrationHooks?.holdMounts(
@@ -2479,7 +2519,7 @@ async function recoverWorkspaceGitHydration(workspacePath, mounts, options = {})
2479
2519
  (mount) => !workspaceHydrationReceiptCoversMount(currentReceipt, currentHead, mount)
2480
2520
  );
2481
2521
  if (workspaceHydrationReceiptMatchesMounts(currentReceipt, currentHead, configuredBasisMounts)) return;
2482
- const busyMountIds = new Set(options.ignoreBusy ? [] : uncoveredBasisMounts.filter((mount) => mount.busy?.()).map(({ id }) => id));
2522
+ const busyMountIds = new Set(options.ignoreBusy ? [] : uncoveredBasisMounts.filter((mount) => mountBusy(mount)).map(({ id }) => id));
2483
2523
  const recoveryMounts = uncoveredBasisMounts.filter(
2484
2524
  (mount) => workspaceHydrationReceiptMountBasisHead(currentReceipt, mount) === null || !options.preserveStaleBases && !busyMountIds.has(mount.id) && workspaceHydrationReceiptMountBasisHead(preservedBases, mount) !== workspaceHydrationReceiptMountBasisHead(currentReceipt, mount)
2485
2525
  );
@@ -2507,7 +2547,7 @@ async function recoverWorkspaceGitHydration(workspacePath, mounts, options = {})
2507
2547
  async function resetWorkspaceGit(input) {
2508
2548
  const workspacePath = import_node_path.default.resolve(input.workspacePath);
2509
2549
  validateMounts(workspacePath, input.mounts);
2510
- const busyResetMountIds = input.mounts.filter((mount) => mount.busy?.()).map(({ id }) => id);
2550
+ const busyResetMountIds = input.mounts.filter((mount) => mountBusy(mount)).map(({ id }) => id);
2511
2551
  if (busyResetMountIds.length > 0) {
2512
2552
  throw new Error(`Workspace reset cannot run while mounts are busy: ${busyResetMountIds.sort().join(", ")}`);
2513
2553
  }
@@ -2539,7 +2579,7 @@ async function resetWorkspaceGit(input) {
2539
2579
  const selected = activeMounts(input.mounts);
2540
2580
  const requiredLiveMountsBeforeHydration = input.mounts.filter(({ sourcePath }) => import_node_fs.default.existsSync(sourcePath));
2541
2581
  const bootstrapMounts = selected.skipped.filter((mount) => {
2542
- if (mount.deleteWhenSourceMissing || import_node_fs.default.existsSync(mount.sourcePath) || mount.busy?.()) return false;
2582
+ if (mount.deleteWhenSourceMissing || import_node_fs.default.existsSync(mount.sourcePath) || mountBusy(mount)) return false;
2543
2583
  const outerPath = import_node_path.default.join(workspacePath, ...mount.workspaceRelativePath.replace(/\\/g, "/").split("/"));
2544
2584
  return import_node_fs.default.existsSync(outerPath);
2545
2585
  });
@@ -2860,10 +2900,12 @@ async function runWorkspaceGitSynchronization(input, remoteHeadFetches) {
2860
2900
  const projectedMountBases = /* @__PURE__ */ new Map();
2861
2901
  const mountSkipReasons = {};
2862
2902
  const staleRewriteLedger = hydrationPreBlobLedger(workspacePath);
2863
- const staleRewriteBlobsFor = (mount) => {
2864
- const preBlobs = staleRewriteLedger.preBlobs(mount);
2865
- return preBlobs.size > 0 ? Object.fromEntries(preBlobs) : void 0;
2903
+ const hydrationLedgerFor = (mount) => {
2904
+ const entries = staleRewriteLedger.entries(mount);
2905
+ return entries.size > 0 ? Object.fromEntries(entries) : void 0;
2866
2906
  };
2907
+ const forceHydrationMountIds = /* @__PURE__ */ new Set();
2908
+ const ledgerMergedPathsByMount = /* @__PURE__ */ new Map();
2867
2909
  const recordStaleRewrite = (mount, paths) => {
2868
2910
  projectionSkippedIds.add(mount.id);
2869
2911
  mountSkipReasons[mount.id] = { reason: "stale_rewrite_detected", paths: [...paths] };
@@ -2877,7 +2919,7 @@ async function runWorkspaceGitSynchronization(input, remoteHeadFetches) {
2877
2919
  for (const mount of mounts) {
2878
2920
  const projectionToken = projectionMutationTokens.get(mount.id);
2879
2921
  if (projectionToken !== void 0 && mount.mutationToken?.() !== projectionToken) cycleSkippedMountIds.add(mount.id);
2880
- if (mount.busy?.()) cycleSkippedMountIds.add(mount.id);
2922
+ if (mountBusy(mount)) cycleSkippedMountIds.add(mount.id);
2881
2923
  }
2882
2924
  };
2883
2925
  const classifyMounts = (activeCandidates, additionalSkippedIds = []) => {
@@ -2900,16 +2942,128 @@ async function runWorkspaceGitSynchronization(input, remoteHeadFetches) {
2900
2942
  };
2901
2943
  };
2902
2944
  const mountProjectionIsCurrent = (mount) => mount.lastProjectedMutationToken !== void 0 && !mount.deleteWhenSourceMissing && mount.preserveLocalOnHydrationBasisChange !== true && projectionBasisHead !== null && workspaceHydrationReceiptMountBasisHead(projectionBasisReceipt, mount) === projectionBasisHead && projectionMutationTokens.get(mount.id) === mount.lastProjectedMutationToken && mount.mutationToken?.() === mount.lastProjectedMutationToken;
2945
+ const mergeProjectMount = async (mount, basisHead, currentHead) => {
2946
+ const releaseMergeHold = input.hydrationHooks?.holdMounts([mount.id], `merge projection of mount ${mount.id}`);
2947
+ let merged;
2948
+ try {
2949
+ const hydrationLedger = hydrationLedgerFor(mount);
2950
+ merged = await (0, import_workspace_filesystem_executor.workspaceFilesystemExecutor)().run("projection_merge", {
2951
+ workspacePath,
2952
+ mount: {
2953
+ id: mount.id,
2954
+ sourcePath: mount.sourcePath,
2955
+ workspaceRelativePath: normalizedWorkspaceMountPath(mount.workspaceRelativePath),
2956
+ sourceMode: mount.sourceMode
2957
+ },
2958
+ basisHead,
2959
+ currentHead,
2960
+ attemptId,
2961
+ ...hydrationLedger ? { hydrationLedger } : {}
2962
+ });
2963
+ } finally {
2964
+ releaseMergeHold?.();
2965
+ }
2966
+ if (merged.staleRewriteCleared.length > 0) staleRewriteLedger.forget(mount, merged.staleRewriteCleared);
2967
+ if (merged.kind === "stale_rewrite") {
2968
+ recordStaleRewrite(mount, merged.paths);
2969
+ return { kind: "skipped" };
2970
+ }
2971
+ if (merged.kind === "conflict" && mount.processLive?.()) {
2972
+ projectionSkippedIds.add(mount.id);
2973
+ mountSkipReasons[mount.id] = { reason: "projection_conflict_deferred", paths: merged.conflictPaths };
2974
+ const holders = mount.describeHolders?.();
2975
+ process.stderr.write(
2976
+ `[r5d-worker] projection of mount ${mount.id} conflicts with the workspace head at ${merged.conflictPaths.join(", ")}; deferred while a process runs in the checkout${holders ? ` (${holders})` : ""}, reported once the mount is idle
2977
+ `
2978
+ );
2979
+ return { kind: "skipped" };
2980
+ }
2981
+ mergedProjectionBases.set(mount.id, {
2982
+ projectedCommit: merged.oursCommit,
2983
+ projectedFiles: merged.projectedFiles,
2984
+ readAtMs: merged.readAtMs
2985
+ });
2986
+ if (merged.kind === "conflict") {
2987
+ const refs = snapshotConflict({ workspacePath, attemptId, localHead: merged.oursCommit, remoteHead: currentHead });
2988
+ return {
2989
+ kind: "conflict",
2990
+ result: {
2991
+ outcome: "conflict_blocked",
2992
+ startingHead,
2993
+ localHead: merged.oursCommit,
2994
+ remoteHead: initial.remoteHead,
2995
+ publishedHead: initial.remoteHead,
2996
+ rebaseCount: 0,
2997
+ diffSizeBytes: 0,
2998
+ affectedPaths: merged.conflictPaths,
2999
+ activeMountIds: [],
3000
+ skippedMountIds: input.mounts.map(({ id }) => id).sort(),
3001
+ conflictPaths: merged.conflictPaths,
3002
+ conflictSnapshotRefs: refs,
3003
+ conflictKind: "projection_merge",
3004
+ ...verifiedAncestorHeads ? { verifiedAncestorHeads } : {},
3005
+ error: merged.error
3006
+ }
3007
+ };
3008
+ }
3009
+ mergedProjectionTrees.set(mount.id, merged.resultTree);
3010
+ mergedProjectionMounts.push(mount);
3011
+ if (merged.ledgerMergedPaths.length > 0) {
3012
+ forceHydrationMountIds.add(mount.id);
3013
+ ledgerMergedPathsByMount.set(mount.id, merged.ledgerMergedPaths);
3014
+ }
3015
+ return { kind: "projected" };
3016
+ };
2903
3017
  if (!input.skipMountMirror) {
2904
3018
  const mergeProjectionEnabled = process.env.R5D_WORKSPACE_MERGE_PROJECTION !== "0";
2905
3019
  const mergeSupport = mergeProjectionEnabled ? (0, import_workspace_merge_projection.workspaceMergeProjectionSupport)() : { supported: false, error: "Workspace merge projection is disabled by R5D_WORKSPACE_MERGE_PROJECTION=0" };
3020
+ const activeProjectionIds = new Set(selected.active.map(({ id }) => id));
2906
3021
  for (const mount of [...selected.active, ...selected.tombstones]) {
2907
3022
  if (deferredMountIds.has(mount.id)) continue;
2908
3023
  const basisHead = workspaceHydrationReceiptMountBasisHead(projectionBasisReceipt, mount);
2909
- if (projectionBasisHead === null || basisHead === projectionBasisHead) {
3024
+ if (projectionBasisHead === null) {
2910
3025
  fastProjectionMounts.push(mount);
2911
3026
  continue;
2912
3027
  }
3028
+ if (basisHead === projectionBasisHead) {
3029
+ const hydrationLedger = activeProjectionIds.has(mount.id) ? hydrationLedgerFor(mount) : void 0;
3030
+ if (!hydrationLedger || !Object.values(hydrationLedger).some(({ processLiveAcrossHydration }) => processLiveAcrossHydration) || !mergeSupport.supported) {
3031
+ fastProjectionMounts.push(mount);
3032
+ continue;
3033
+ }
3034
+ if (cycleSkippedMountIds.has(mount.id) || mountBusy(mount)) {
3035
+ cycleSkippedMountIds.add(mount.id);
3036
+ continue;
3037
+ }
3038
+ const releaseCheckHold = input.hydrationHooks?.holdMounts([mount.id], `hydration ledger check of mount ${mount.id}`);
3039
+ let checked;
3040
+ try {
3041
+ checked = await (0, import_workspace_filesystem_executor.workspaceFilesystemExecutor)().run("projection_mirror", {
3042
+ workspacePath,
3043
+ mount: workspaceGitMountData(mount),
3044
+ hydrationLedger,
3045
+ checkOnly: true
3046
+ });
3047
+ } finally {
3048
+ releaseCheckHold?.();
3049
+ }
3050
+ if (checked.staleRewriteCleared.length > 0) staleRewriteLedger.forget(mount, checked.staleRewriteCleared);
3051
+ if (checked.staleRewritePaths.length > 0) {
3052
+ recordStaleRewrite(mount, checked.staleRewritePaths);
3053
+ continue;
3054
+ }
3055
+ if (checked.ledgerMergePaths.length === 0) {
3056
+ fastProjectionMounts.push(mount);
3057
+ continue;
3058
+ }
3059
+ if (cycleSkippedMountIds.has(mount.id) || mountBusy(mount)) {
3060
+ cycleSkippedMountIds.add(mount.id);
3061
+ continue;
3062
+ }
3063
+ const outcome2 = await mergeProjectMount(mount, projectionBasisHead, projectionBasisHead);
3064
+ if (outcome2.kind === "conflict") return outcome2.result;
3065
+ continue;
3066
+ }
2913
3067
  if (basisHead === null) {
2914
3068
  deferredMountIds.add(mount.id);
2915
3069
  continue;
@@ -2919,62 +3073,12 @@ async function runWorkspaceGitSynchronization(input, remoteHeadFetches) {
2919
3073
  projectionWarning = mergeSupport.error ?? "Git 2.40 or newer is required for stale workspace mount projection";
2920
3074
  continue;
2921
3075
  }
2922
- if (cycleSkippedMountIds.has(mount.id) || mount.busy?.()) {
3076
+ if (cycleSkippedMountIds.has(mount.id) || mountBusy(mount)) {
2923
3077
  cycleSkippedMountIds.add(mount.id);
2924
3078
  continue;
2925
3079
  }
2926
- const releaseMergeHold = input.hydrationHooks?.holdMounts([mount.id], `merge projection of mount ${mount.id}`);
2927
- let merged;
2928
- try {
2929
- const staleRewriteBlobs = staleRewriteBlobsFor(mount);
2930
- merged = await (0, import_workspace_filesystem_executor.workspaceFilesystemExecutor)().run("projection_merge", {
2931
- workspacePath,
2932
- mount: {
2933
- id: mount.id,
2934
- sourcePath: mount.sourcePath,
2935
- workspaceRelativePath: normalizedWorkspaceMountPath(mount.workspaceRelativePath),
2936
- sourceMode: mount.sourceMode
2937
- },
2938
- basisHead,
2939
- currentHead: projectionBasisHead,
2940
- attemptId,
2941
- ...staleRewriteBlobs ? { staleRewriteBlobs } : {}
2942
- });
2943
- } finally {
2944
- releaseMergeHold?.();
2945
- }
2946
- if (merged.staleRewriteCleared.length > 0) staleRewriteLedger.forget(mount, merged.staleRewriteCleared);
2947
- if (merged.kind === "stale_rewrite") {
2948
- recordStaleRewrite(mount, merged.paths);
2949
- continue;
2950
- }
2951
- mergedProjectionBases.set(mount.id, {
2952
- projectedCommit: merged.oursCommit,
2953
- projectedFiles: merged.projectedFiles,
2954
- readAtMs: merged.readAtMs
2955
- });
2956
- if (merged.kind === "conflict") {
2957
- const refs = snapshotConflict({ workspacePath, attemptId, localHead: merged.oursCommit, remoteHead: projectionBasisHead });
2958
- return {
2959
- outcome: "conflict_blocked",
2960
- startingHead,
2961
- localHead: merged.oursCommit,
2962
- remoteHead: initial.remoteHead,
2963
- publishedHead: initial.remoteHead,
2964
- rebaseCount: 0,
2965
- diffSizeBytes: 0,
2966
- affectedPaths: merged.conflictPaths,
2967
- activeMountIds: [],
2968
- skippedMountIds: input.mounts.map(({ id }) => id).sort(),
2969
- conflictPaths: merged.conflictPaths,
2970
- conflictSnapshotRefs: refs,
2971
- conflictKind: "projection_merge",
2972
- ...verifiedAncestorHeads ? { verifiedAncestorHeads } : {},
2973
- error: merged.error
2974
- };
2975
- }
2976
- mergedProjectionTrees.set(mount.id, merged.resultTree);
2977
- mergedProjectionMounts.push(mount);
3080
+ const outcome = await mergeProjectMount(mount, basisHead, projectionBasisHead);
3081
+ if (outcome.kind === "conflict") return outcome.result;
2978
3082
  }
2979
3083
  }
2980
3084
  if (projectionWarning) process.stderr.write(`[r5d-worker] ${projectionWarning}
@@ -2984,7 +3088,7 @@ async function runWorkspaceGitSynchronization(input, remoteHeadFetches) {
2984
3088
  const completionReceiptMounts = configuredWorkspaceHydrationBasisMounts(workspacePath, input.mounts);
2985
3089
  const currentReceiptBeforeHydration = await readHydratedWorkspaceReceiptAsync(workspacePath);
2986
3090
  refreshCycleSkippedMounts(completionReceiptMounts);
2987
- const mountIsAlreadyCovered = (mount) => Boolean(
3091
+ const mountIsAlreadyCovered = (mount) => !forceHydrationMountIds.has(mount.id) && Boolean(
2988
3092
  currentHeadBeforeHydration && workspaceHydrationReceiptCoversMount(currentReceiptBeforeHydration, currentHeadBeforeHydration, mount)
2989
3093
  );
2990
3094
  if (currentHeadBeforeHydration && completionReceiptMounts.every((mount) => cycleSkippedMountIds.has(mount.id) || mountIsAlreadyCovered(mount))) {
@@ -3005,6 +3109,7 @@ async function runWorkspaceGitSynchronization(input, remoteHeadFetches) {
3005
3109
  currentHeadBeforeHydration,
3006
3110
  uncoveredCompletionMounts
3007
3111
  ) : /* @__PURE__ */ new Set();
3112
+ for (const id of forceHydrationMountIds) unchangedMountIds.delete(id);
3008
3113
  const hydrationMounts = uncoveredCompletionMounts.filter(({ id }) => !unchangedMountIds.has(id));
3009
3114
  const mergeBases = /* @__PURE__ */ new Map();
3010
3115
  if (workspaceMergeHydrationEnabled()) {
@@ -3022,9 +3127,19 @@ async function runWorkspaceGitSynchronization(input, remoteHeadFetches) {
3022
3127
  requiredMounts: uncoveredRequiredLiveMounts.filter(({ id }) => !unchangedMountIds.has(id)),
3023
3128
  recordCurrentHead: true,
3024
3129
  hydrationHooks: input.hydrationHooks,
3025
- ...mergeBases.size > 0 ? { mergeBases } : {}
3130
+ ...mergeBases.size > 0 ? { mergeBases } : {},
3131
+ ...ledgerMergedPathsByMount.size > 0 ? { ledgerMergedPaths: ledgerMergedPathsByMount } : {}
3026
3132
  });
3027
3133
  for (const id of hydration.skippedMountIds) cycleSkippedMountIds.add(id);
3134
+ for (const mount of hydrationMounts) {
3135
+ const mergedPaths = ledgerMergedPathsByMount.get(mount.id);
3136
+ if (!mergedPaths || !hydration.hydratedMountIds.includes(mount.id)) continue;
3137
+ const written = new Set(hydration.hydratedEntryPaths[mount.id] ?? []);
3138
+ const settled = mergedPaths.filter((relativePath) => !written.has(relativePath));
3139
+ if (settled.length > 0) staleRewriteLedger.forget(mount, settled);
3140
+ ledgerMergedPathsByMount.delete(mount.id);
3141
+ forceHydrationMountIds.delete(mount.id);
3142
+ }
3028
3143
  if (currentHeadBeforeHydration) {
3029
3144
  for (const id of hydration.hydratedMountIds) {
3030
3145
  if (mergeBases.has(id)) projectedMountBases.set(id, { projectedCommit: currentHeadBeforeHydration, projectedFiles: null });
@@ -3049,7 +3164,7 @@ async function runWorkspaceGitSynchronization(input, remoteHeadFetches) {
3049
3164
  const receipt = await readHydratedWorkspaceReceiptAsync(workspacePath);
3050
3165
  const transactionSkippedMountIds = new Set(hydration.skippedMountIds);
3051
3166
  const idleMounts = completionReceiptMounts.filter(
3052
- (mount) => !cycleSkippedMountIds.has(mount.id) && !transactionSkippedMountIds.has(mount.id) && !mount.busy?.() && !projectionSkippedIds.has(mount.id)
3167
+ (mount) => !cycleSkippedMountIds.has(mount.id) && !transactionSkippedMountIds.has(mount.id) && !mountBusy(mount) && !projectionSkippedIds.has(mount.id)
3053
3168
  );
3054
3169
  if (currentHead && !idleMounts.every((mount) => workspaceHydrationReceiptCoversMount(receipt, currentHead, mount))) {
3055
3170
  throw new Error(`Workspace inbound integration ${currentHead} could not be fully hydrated before publication continued`);
@@ -3091,7 +3206,7 @@ async function runWorkspaceGitSynchronization(input, remoteHeadFetches) {
3091
3206
  const fastActiveMounts = fastProjectionMounts.filter(({ id }) => selectedActiveIds.has(id));
3092
3207
  const fastTombstones = fastProjectionMounts.filter(({ id }) => !selectedActiveIds.has(id));
3093
3208
  for (const mount of fastActiveMounts) {
3094
- if (cycleSkippedMountIds.has(mount.id) || mount.busy?.()) {
3209
+ if (cycleSkippedMountIds.has(mount.id) || mountBusy(mount)) {
3095
3210
  cycleSkippedMountIds.add(mount.id);
3096
3211
  continue;
3097
3212
  }
@@ -3101,22 +3216,33 @@ async function runWorkspaceGitSynchronization(input, remoteHeadFetches) {
3101
3216
  }
3102
3217
  const releaseMirrorHold = input.hydrationHooks?.holdMounts([mount.id], `projection of mount ${mount.id}`);
3103
3218
  let staleRewritePaths;
3219
+ let ledgerMergePaths;
3104
3220
  try {
3105
- const staleRewriteBlobs = staleRewriteBlobsFor(mount);
3221
+ const hydrationLedger = hydrationLedgerFor(mount);
3106
3222
  const mirrored = await (0, import_workspace_filesystem_executor.workspaceFilesystemExecutor)().run("projection_mirror", {
3107
3223
  workspacePath,
3108
3224
  mount: workspaceGitMountData(mount),
3109
- ...staleRewriteBlobs ? { staleRewriteBlobs } : {}
3225
+ ...hydrationLedger ? { hydrationLedger } : {}
3110
3226
  });
3111
3227
  staleRewritePaths = mirrored.staleRewritePaths;
3228
+ ledgerMergePaths = mirrored.ledgerMergePaths;
3112
3229
  if (mirrored.staleRewriteCleared.length > 0) staleRewriteLedger.forget(mount, mirrored.staleRewriteCleared);
3113
- if (staleRewritePaths.length === 0) {
3230
+ if (staleRewritePaths.length === 0 && ledgerMergePaths.length === 0) {
3114
3231
  projectedGitlinks.push({ mount, gitlinks: mirrored.gitlinks });
3115
3232
  projectedMountFiles.set(mount.id, { files: mirrored.projectedFiles, readAtMs: mirrored.readAtMs });
3116
3233
  }
3117
3234
  } finally {
3118
3235
  releaseMirrorHold?.();
3119
3236
  }
3237
+ if (ledgerMergePaths.length > 0) {
3238
+ projectionSkippedIds.add(mount.id);
3239
+ mountSkipReasons[mount.id] = { reason: "hydration_target_changed", paths: ledgerMergePaths };
3240
+ process.stderr.write(
3241
+ `[r5d-worker] projection of mount ${mount.id} skipped: a running process rewrote ${(0, import_workspace_hydration_merge.describeHydrationSkipPaths)(mount.sourcePath, ledgerMergePaths)} while the cycle prepared the mirror; the next cycle merges it
3242
+ `
3243
+ );
3244
+ continue;
3245
+ }
3120
3246
  if (staleRewritePaths.length > 0) {
3121
3247
  recordStaleRewrite(mount, staleRewritePaths);
3122
3248
  continue;
@@ -3338,42 +3464,59 @@ ${push.stdout}`)) {
3338
3464
  }
3339
3465
  throw new Error(`Workspace push did not converge after ${maxPushAttempts} attempts`);
3340
3466
  }
3341
- function detectStaleRewrites(workspacePath, mount, staleRewriteBlobs) {
3467
+ function inspectHydrationLedger(workspacePath, mount, hydrationLedger) {
3342
3468
  const outerRoot = workspaceMountOuterPath(workspacePath, mount);
3343
3469
  const stale = [];
3470
+ const merge = [];
3344
3471
  const cleared = [];
3345
- for (const [relativePath, blob] of Object.entries(staleRewriteBlobs)) {
3346
- const algorithm = (0, import_git_blob_hash.gitObjectHashAlgorithmFor)(blob);
3472
+ for (const [relativePath, entry] of Object.entries(hydrationLedger)) {
3473
+ const algorithm = (0, import_git_blob_hash.gitObjectHashAlgorithmFor)(entry.preBlob);
3347
3474
  if (!algorithm) continue;
3348
3475
  const source = (0, import_working_tree_mirror.inspectWorkingTreePath)(mount.sourcePath, relativePath);
3349
3476
  if (source.kind !== "entry" || !source.stat.isFile()) {
3350
- cleared.push(relativePath);
3477
+ if (entry.postBlob !== null) cleared.push(relativePath);
3351
3478
  continue;
3352
3479
  }
3480
+ if (entry.stat && (0, import_working_tree_mirror.projectedFileEntryMatches)(entry.stat, source.stat)) continue;
3353
3481
  const content = import_node_fs.default.readFileSync(source.absolutePath);
3482
+ const hash = (0, import_git_blob_hash.gitBlobHash)(content, algorithm);
3483
+ if (hash === entry.postBlob) continue;
3354
3484
  const outer = (0, import_working_tree_mirror.inspectWorkingTreePath)(outerRoot, relativePath);
3355
3485
  const outerEqual = outer.kind === "entry" && outer.stat.isFile() && import_node_fs.default.readFileSync(outer.absolutePath).equals(content);
3356
- if ((0, import_git_blob_hash.gitBlobHash)(content, algorithm) !== blob) {
3357
- if (!outerEqual) cleared.push(relativePath);
3486
+ if (outerEqual) {
3487
+ cleared.push(relativePath);
3358
3488
  continue;
3359
3489
  }
3360
- if (outerEqual) {
3490
+ if (entry.processLiveAcrossHydration) {
3491
+ merge.push(relativePath);
3492
+ continue;
3493
+ }
3494
+ if (hash !== entry.preBlob) {
3361
3495
  cleared.push(relativePath);
3362
3496
  continue;
3363
3497
  }
3364
3498
  stale.push(relativePath);
3365
3499
  }
3366
- return { stale: stale.sort(), cleared: cleared.sort() };
3500
+ return { stale: stale.sort(), merge: merge.sort(), cleared: cleared.sort() };
3367
3501
  }
3368
3502
  function runProjectionMirrorJob(input) {
3369
3503
  const workspacePath = import_node_path.default.resolve(input.workspacePath);
3370
3504
  let staleRewriteCleared = [];
3371
- if (input.staleRewriteBlobs) {
3372
- const detected = detectStaleRewrites(workspacePath, input.mount, input.staleRewriteBlobs);
3373
- staleRewriteCleared = detected.cleared;
3374
- if (detected.stale.length > 0) {
3375
- return { gitlinks: [], projectedFiles: {}, readAtMs: Date.now(), staleRewritePaths: detected.stale, staleRewriteCleared };
3505
+ if (input.hydrationLedger) {
3506
+ const inspected = inspectHydrationLedger(workspacePath, input.mount, input.hydrationLedger);
3507
+ staleRewriteCleared = inspected.cleared;
3508
+ if (inspected.stale.length > 0 || inspected.merge.length > 0 || input.checkOnly) {
3509
+ return {
3510
+ gitlinks: [],
3511
+ projectedFiles: {},
3512
+ readAtMs: Date.now(),
3513
+ staleRewritePaths: inspected.stale,
3514
+ ledgerMergePaths: inspected.merge,
3515
+ staleRewriteCleared
3516
+ };
3376
3517
  }
3518
+ } else if (input.checkOnly) {
3519
+ return { gitlinks: [], projectedFiles: {}, readAtMs: Date.now(), staleRewritePaths: [], ledgerMergePaths: [], staleRewriteCleared };
3377
3520
  }
3378
3521
  const projectedFiles = /* @__PURE__ */ new Map();
3379
3522
  const projected = mirrorMountsToWorkspace(workspacePath, [input.mount], projectedFiles);
@@ -3382,6 +3525,7 @@ function runProjectionMirrorJob(input) {
3382
3525
  projectedFiles: Object.fromEntries(projectedFiles),
3383
3526
  readAtMs: Date.now(),
3384
3527
  staleRewritePaths: [],
3528
+ ledgerMergePaths: [],
3385
3529
  staleRewriteCleared
3386
3530
  };
3387
3531
  }
@@ -3422,6 +3566,7 @@ const workspaceGitSyncTestHarness = {
3422
3566
  WORKSPACE_GIT_HYDRATION_RECOVERIES,
3423
3567
  WORKSPACE_GIT_HYDRATION_TRANSACTION,
3424
3568
  WORKSPACE_MERGE_HYDRATION_SWITCH,
3569
+ WORKSPACE_PROCESS_BUSY_FENCE_SWITCH,
3425
3570
  WorkspaceHydrationRecoveryRequiredError,
3426
3571
  WorkspaceRemediationAncestryError,
3427
3572
  configureExistingWorkspaceGitForRemediation,