@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.
@@ -13,6 +13,8 @@ import {
13
13
  fsyncWorkingTreePaths,
14
14
  inspectWorkingTree,
15
15
  inspectWorkingTreePath,
16
+ projectedFileEntry,
17
+ projectedFileEntryMatches,
16
18
  mirrorWorkingTree,
17
19
  normalizeWorkingTreeRelativePath,
18
20
  planPreparedWorkingTreeMirror,
@@ -29,7 +31,7 @@ import {
29
31
  describeHydrationSkipPaths,
30
32
  parseWorkspaceSubtreeChanges
31
33
  } from "./workspace-hydration-merge.mjs";
32
- import { WorkspaceHydrationPreBlobLedger } from "./workspace-hydration-ledger.mjs";
34
+ import { WorkspaceHydrationLedger } from "./workspace-hydration-ledger.mjs";
33
35
  import { assertManagedDirectoryPath } from "./workspace-mount-boundary.mjs";
34
36
  const WORKSPACE_GIT_BRANCH = "main";
35
37
  const MAX_WORKSPACE_GIT_DIFF_BYTES = 5 * 1024 * 1024;
@@ -44,12 +46,20 @@ const WORKSPACE_MERGE_HYDRATION_SWITCH = "R5D_MERGE_HYDRATION";
44
46
  function workspaceMergeHydrationEnabled() {
45
47
  return process.env[WORKSPACE_MERGE_HYDRATION_SWITCH] !== "0";
46
48
  }
49
+ const WORKSPACE_PROCESS_BUSY_FENCE_SWITCH = "R5D_PROCESS_BUSY_FENCE";
50
+ function workspaceProcessBusyFenceEnabled() {
51
+ return process.env[WORKSPACE_PROCESS_BUSY_FENCE_SWITCH] === "1";
52
+ }
53
+ function mountBusy(mount) {
54
+ if (mount.busy !== void 0 && mount.busy()) return true;
55
+ return workspaceProcessBusyFenceEnabled() && mount.processLive?.() === true;
56
+ }
47
57
  const hydrationPreBlobLedgers = /* @__PURE__ */ new Map();
48
58
  function hydrationPreBlobLedger(workspacePath) {
49
59
  const key = path.resolve(workspacePath);
50
60
  let ledger = hydrationPreBlobLedgers.get(key);
51
61
  if (!ledger) {
52
- ledger = new WorkspaceHydrationPreBlobLedger();
62
+ ledger = new WorkspaceHydrationLedger();
53
63
  hydrationPreBlobLedgers.set(key, ledger);
54
64
  }
55
65
  return ledger;
@@ -489,7 +499,7 @@ function workspaceGitHydrationHolds(workspacePath, mounts, tolerateIdleRecordedB
489
499
  if (head === null) return true;
490
500
  if (!receipt) return false;
491
501
  return mounts ? configuredWorkspaceHydrationBasisMounts(resolvedWorkspacePath, mounts).every(
492
- (mount) => workspaceHydrationReceiptCoversMount(receipt, head, mount) || Boolean((tolerateIdleRecordedBasis || mount.busy?.()) && workspaceHydrationReceiptMountBasisHead(receipt, mount))
502
+ (mount) => workspaceHydrationReceiptCoversMount(receipt, head, mount) || Boolean((tolerateIdleRecordedBasis || mountBusy(mount)) && workspaceHydrationReceiptMountBasisHead(receipt, mount))
493
503
  ) : Boolean(receipt && receipt.mounts.every((mount) => tolerateIdleRecordedBasis || mount.head === head));
494
504
  }
495
505
  const WORKSPACE_GIT_BASIS_REF_PREFIX = "refs/r5d/workspace-basis/";
@@ -1450,6 +1460,12 @@ function resolveGitObjectsSync(workspacePath, names) {
1450
1460
  });
1451
1461
  return resolved;
1452
1462
  }
1463
+ function storeWorkspaceBlobs(workspacePath, blobs) {
1464
+ for (const [objectId, content] of blobs) {
1465
+ const written = gitOutputSync(workspacePath, ["hash-object", "-w", "-t", "blob", "--no-filters", "--stdin"], "store hydration ledger blob", content).toString().trim();
1466
+ if (written !== objectId) throw new Error(`Hydration ledger blob ${objectId} was stored as ${written}`);
1467
+ }
1468
+ }
1453
1469
  function readWorkspaceBlobs(workspacePath, objectIds) {
1454
1470
  const blobs = /* @__PURE__ */ new Map();
1455
1471
  const unique = [...new Set(objectIds)];
@@ -1941,7 +1957,7 @@ function activeMounts(mounts) {
1941
1957
  const tombstones = [];
1942
1958
  const skipped = [];
1943
1959
  for (const mount of mounts) {
1944
- if (mount.busy?.()) skipped.push(mount);
1960
+ if (mountBusy(mount)) skipped.push(mount);
1945
1961
  else if (fs.existsSync(mount.sourcePath)) active.push(mount);
1946
1962
  else if (mount.deleteWhenSourceMissing) tombstones.push(mount);
1947
1963
  else skipped.push(mount);
@@ -2079,9 +2095,9 @@ function hydrateWorkspaceGitMountsRaw(workspacePath, mounts, options = {}) {
2079
2095
  const skippedMountIds = [];
2080
2096
  const mergeSkips = [];
2081
2097
  const rollbackScopes = /* @__PURE__ */ new Map();
2082
- const preBlobs = {};
2098
+ const ledgerEntries = {};
2083
2099
  for (const mount of mounts) {
2084
- if (!options.ignoreBusy && mount.busy?.()) {
2100
+ if (!options.ignoreBusy && mountBusy(mount)) {
2085
2101
  skippedMountIds.push(mount.id);
2086
2102
  continue;
2087
2103
  }
@@ -2128,7 +2144,20 @@ function hydrateWorkspaceGitMountsRaw(workspacePath, mounts, options = {}) {
2128
2144
  continue;
2129
2145
  }
2130
2146
  hydratedMountIds.push(mount.id);
2131
- preBlobs[mount.id] = Object.fromEntries(mergePlan.decision.preBlobs);
2147
+ storeWorkspaceBlobs(workspacePath, mergePlan.decision.blobsToStore);
2148
+ const left = /* @__PURE__ */ new Map();
2149
+ for (const entry of journal.entries) left.set(entry.relativePath, entry.after);
2150
+ const entries = {};
2151
+ for (const [relativePath, preBlob] of mergePlan.decision.preBlobs) {
2152
+ const postBlob = mergePlan.decision.postBlobs.get(relativePath) ?? null;
2153
+ let after = left.get(relativePath);
2154
+ if (after === void 0) {
2155
+ const current = inspectWorkingTreePath(mount.sourcePath, relativePath);
2156
+ after = current.kind === "entry" ? current.stat : null;
2157
+ }
2158
+ entries[relativePath] = { preBlob, postBlob, stat: after && after.isFile() ? projectedFileEntry(after) : null };
2159
+ }
2160
+ ledgerEntries[mount.id] = entries;
2132
2161
  continue;
2133
2162
  }
2134
2163
  const sourceRoot = workspaceMountOuterPath(workspacePath, mount);
@@ -2176,7 +2205,7 @@ function hydrateWorkspaceGitMountsRaw(workspacePath, mounts, options = {}) {
2176
2205
  });
2177
2206
  hydratedMountIds.push(mount.id);
2178
2207
  }
2179
- return { hydratedMountIds: hydratedMountIds.sort(), skippedMountIds: skippedMountIds.sort(), mergeSkips, rollbackScopes, preBlobs };
2208
+ return { hydratedMountIds: hydratedMountIds.sort(), skippedMountIds: skippedMountIds.sort(), mergeSkips, rollbackScopes, ledgerEntries };
2180
2209
  }
2181
2210
  async function selectMountsWithUnchangedOuterSubtree(workspacePath, receipt, head, mounts) {
2182
2211
  const candidates = mounts.flatMap((mount) => {
@@ -2217,7 +2246,7 @@ async function hydrateWorkspaceGitMountsTransactionally(input) {
2217
2246
  const targetHead = input.recordCurrentHead ? await revParseAsync(workspacePath, "HEAD") : null;
2218
2247
  const receiptBeforeTransaction = targetHead ? await readHydratedWorkspaceReceiptAsync(workspacePath) : null;
2219
2248
  const computeSelection = () => {
2220
- const busyMountIds = new Set(input.ignoreBusy ? [] : candidateMounts.filter((mount) => mount.busy?.()).map(({ id }) => id));
2249
+ const busyMountIds = new Set(input.ignoreBusy ? [] : candidateMounts.filter((mount) => mountBusy(mount)).map(({ id }) => id));
2221
2250
  const requestedIds = new Set(input.mounts.map(({ id }) => id));
2222
2251
  const requiredIds = new Set(requiredMounts.map(({ id }) => id));
2223
2252
  const hydrationMounts = input.mounts.filter(({ id }) => !busyMountIds.has(id));
@@ -2236,7 +2265,7 @@ async function hydrateWorkspaceGitMountsTransactionally(input) {
2236
2265
  };
2237
2266
  const noopCandidate = computeSelection();
2238
2267
  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)) {
2239
- return { hydratedMountIds: [], skippedMountIds: noopCandidate.skippedMountIds, mergeSkips: [] };
2268
+ return { hydratedMountIds: [], skippedMountIds: noopCandidate.skippedMountIds, mergeSkips: [], hydratedEntryPaths: {} };
2240
2269
  }
2241
2270
  const hooks = input.hydrationHooks;
2242
2271
  const context = {
@@ -2256,6 +2285,7 @@ async function hydrateWorkspaceGitMountsTransactionally(input) {
2256
2285
  const basis = input.mergeBases?.get(mount.id);
2257
2286
  if (basis) mergeBases[mount.id] = basis;
2258
2287
  }
2288
+ const liveBefore = new Set(hydrationMounts.filter((mount) => mergeBases[mount.id] && mount.processLive?.()).map(({ id }) => id));
2259
2289
  const hydration = await workspaceFilesystemExecutor().run("hydration_transaction", {
2260
2290
  workspacePath,
2261
2291
  hydrationMounts: hydrationMounts.map(workspaceGitMountData),
@@ -2264,16 +2294,27 @@ async function hydrateWorkspaceGitMountsTransactionally(input) {
2264
2294
  ...Object.keys(mergeBases).length > 0 ? { mergeBases } : {}
2265
2295
  });
2266
2296
  const ledger = hydrationPreBlobLedger(workspacePath);
2297
+ const hydratedEntryPaths = {};
2267
2298
  for (const mount of hydrationMounts) {
2268
2299
  if (!hydration.hydratedMountIds.includes(mount.id)) continue;
2269
- const preBlobs = mergeBases[mount.id] ? hydration.hydratedPreBlobs[mount.id] : void 0;
2270
- if (preBlobs) ledger.record(mount, preBlobs);
2271
- else ledger.clearMount(mount);
2300
+ const entries = mergeBases[mount.id] ? hydration.hydratedEntries[mount.id] : void 0;
2301
+ if (!entries) {
2302
+ ledger.clearMount(mount);
2303
+ continue;
2304
+ }
2305
+ const processLiveAcrossHydration = liveBefore.has(mount.id) || mount.processLive?.() === true;
2306
+ hydratedEntryPaths[mount.id] = Object.keys(entries).sort();
2307
+ ledger.record(
2308
+ mount,
2309
+ Object.fromEntries(Object.entries(entries).map(([relativePath, entry]) => [relativePath, { ...entry, processLiveAcrossHydration }])),
2310
+ { replacePreBlobFor: input.ledgerMergedPaths?.get(mount.id) ?? [] }
2311
+ );
2272
2312
  }
2273
2313
  return {
2274
2314
  hydratedMountIds: hydration.hydratedMountIds,
2275
2315
  skippedMountIds: [.../* @__PURE__ */ new Set([...selection.skippedMountIds, ...hydration.skippedMounts.map(({ mountId }) => mountId)])].sort(),
2276
- mergeSkips: hydration.skippedMounts
2316
+ mergeSkips: hydration.skippedMounts,
2317
+ hydratedEntryPaths
2277
2318
  };
2278
2319
  } finally {
2279
2320
  releaseHold?.();
@@ -2338,7 +2379,7 @@ function runHydrationTransactionJob(input) {
2338
2379
  markHydrationTransactionDurable(workspacePath);
2339
2380
  if (targetReceipt) updateHydratedWorkspaceReceipt(workspacePath, targetReceipt);
2340
2381
  removeHydrationTransaction(workspacePath);
2341
- return { hydratedMountIds: hydration.hydratedMountIds, skippedMounts, hydratedPreBlobs: hydration.preBlobs };
2382
+ return { hydratedMountIds: hydration.hydratedMountIds, skippedMounts, hydratedEntries: hydration.ledgerEntries };
2342
2383
  } catch (error) {
2343
2384
  const transactionPath = hydrationTransactionPath(workspacePath);
2344
2385
  if (lstatIfExists(transactionPath) && hydrationTransactionCommitted(transactionPath)) {
@@ -2392,7 +2433,7 @@ async function hydrateWorkspaceGitMounts(workspacePath, mounts, options = {}) {
2392
2433
  const resolvedWorkspacePath = path.resolve(workspacePath);
2393
2434
  const gitDirectory = path.join(resolvedWorkspacePath, ".git");
2394
2435
  if (!fs.existsSync(gitDirectory)) {
2395
- const skippedMountIds = options.ignoreBusy ? [] : mounts.filter((mount) => mount.busy?.()).map(({ id }) => id).sort();
2436
+ const skippedMountIds = options.ignoreBusy ? [] : mounts.filter((mount) => mountBusy(mount)).map(({ id }) => id).sort();
2396
2437
  const skipped = new Set(skippedMountIds);
2397
2438
  const selected = mounts.filter(({ id }) => !skipped.has(id));
2398
2439
  const releaseHold = options.hydrationHooks?.holdMounts(
@@ -2437,7 +2478,7 @@ async function recoverWorkspaceGitHydration(workspacePath, mounts, options = {})
2437
2478
  (mount) => !workspaceHydrationReceiptCoversMount(currentReceipt, currentHead, mount)
2438
2479
  );
2439
2480
  if (workspaceHydrationReceiptMatchesMounts(currentReceipt, currentHead, configuredBasisMounts)) return;
2440
- const busyMountIds = new Set(options.ignoreBusy ? [] : uncoveredBasisMounts.filter((mount) => mount.busy?.()).map(({ id }) => id));
2481
+ const busyMountIds = new Set(options.ignoreBusy ? [] : uncoveredBasisMounts.filter((mount) => mountBusy(mount)).map(({ id }) => id));
2441
2482
  const recoveryMounts = uncoveredBasisMounts.filter(
2442
2483
  (mount) => workspaceHydrationReceiptMountBasisHead(currentReceipt, mount) === null || !options.preserveStaleBases && !busyMountIds.has(mount.id) && workspaceHydrationReceiptMountBasisHead(preservedBases, mount) !== workspaceHydrationReceiptMountBasisHead(currentReceipt, mount)
2443
2484
  );
@@ -2465,7 +2506,7 @@ async function recoverWorkspaceGitHydration(workspacePath, mounts, options = {})
2465
2506
  async function resetWorkspaceGit(input) {
2466
2507
  const workspacePath = path.resolve(input.workspacePath);
2467
2508
  validateMounts(workspacePath, input.mounts);
2468
- const busyResetMountIds = input.mounts.filter((mount) => mount.busy?.()).map(({ id }) => id);
2509
+ const busyResetMountIds = input.mounts.filter((mount) => mountBusy(mount)).map(({ id }) => id);
2469
2510
  if (busyResetMountIds.length > 0) {
2470
2511
  throw new Error(`Workspace reset cannot run while mounts are busy: ${busyResetMountIds.sort().join(", ")}`);
2471
2512
  }
@@ -2497,7 +2538,7 @@ async function resetWorkspaceGit(input) {
2497
2538
  const selected = activeMounts(input.mounts);
2498
2539
  const requiredLiveMountsBeforeHydration = input.mounts.filter(({ sourcePath }) => fs.existsSync(sourcePath));
2499
2540
  const bootstrapMounts = selected.skipped.filter((mount) => {
2500
- if (mount.deleteWhenSourceMissing || fs.existsSync(mount.sourcePath) || mount.busy?.()) return false;
2541
+ if (mount.deleteWhenSourceMissing || fs.existsSync(mount.sourcePath) || mountBusy(mount)) return false;
2501
2542
  const outerPath = path.join(workspacePath, ...mount.workspaceRelativePath.replace(/\\/g, "/").split("/"));
2502
2543
  return fs.existsSync(outerPath);
2503
2544
  });
@@ -2818,10 +2859,12 @@ async function runWorkspaceGitSynchronization(input, remoteHeadFetches) {
2818
2859
  const projectedMountBases = /* @__PURE__ */ new Map();
2819
2860
  const mountSkipReasons = {};
2820
2861
  const staleRewriteLedger = hydrationPreBlobLedger(workspacePath);
2821
- const staleRewriteBlobsFor = (mount) => {
2822
- const preBlobs = staleRewriteLedger.preBlobs(mount);
2823
- return preBlobs.size > 0 ? Object.fromEntries(preBlobs) : void 0;
2862
+ const hydrationLedgerFor = (mount) => {
2863
+ const entries = staleRewriteLedger.entries(mount);
2864
+ return entries.size > 0 ? Object.fromEntries(entries) : void 0;
2824
2865
  };
2866
+ const forceHydrationMountIds = /* @__PURE__ */ new Set();
2867
+ const ledgerMergedPathsByMount = /* @__PURE__ */ new Map();
2825
2868
  const recordStaleRewrite = (mount, paths) => {
2826
2869
  projectionSkippedIds.add(mount.id);
2827
2870
  mountSkipReasons[mount.id] = { reason: "stale_rewrite_detected", paths: [...paths] };
@@ -2835,7 +2878,7 @@ async function runWorkspaceGitSynchronization(input, remoteHeadFetches) {
2835
2878
  for (const mount of mounts) {
2836
2879
  const projectionToken = projectionMutationTokens.get(mount.id);
2837
2880
  if (projectionToken !== void 0 && mount.mutationToken?.() !== projectionToken) cycleSkippedMountIds.add(mount.id);
2838
- if (mount.busy?.()) cycleSkippedMountIds.add(mount.id);
2881
+ if (mountBusy(mount)) cycleSkippedMountIds.add(mount.id);
2839
2882
  }
2840
2883
  };
2841
2884
  const classifyMounts = (activeCandidates, additionalSkippedIds = []) => {
@@ -2858,16 +2901,128 @@ async function runWorkspaceGitSynchronization(input, remoteHeadFetches) {
2858
2901
  };
2859
2902
  };
2860
2903
  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;
2904
+ const mergeProjectMount = async (mount, basisHead, currentHead) => {
2905
+ const releaseMergeHold = input.hydrationHooks?.holdMounts([mount.id], `merge projection of mount ${mount.id}`);
2906
+ let merged;
2907
+ try {
2908
+ const hydrationLedger = hydrationLedgerFor(mount);
2909
+ merged = await workspaceFilesystemExecutor().run("projection_merge", {
2910
+ workspacePath,
2911
+ mount: {
2912
+ id: mount.id,
2913
+ sourcePath: mount.sourcePath,
2914
+ workspaceRelativePath: normalizedWorkspaceMountPath(mount.workspaceRelativePath),
2915
+ sourceMode: mount.sourceMode
2916
+ },
2917
+ basisHead,
2918
+ currentHead,
2919
+ attemptId,
2920
+ ...hydrationLedger ? { hydrationLedger } : {}
2921
+ });
2922
+ } finally {
2923
+ releaseMergeHold?.();
2924
+ }
2925
+ if (merged.staleRewriteCleared.length > 0) staleRewriteLedger.forget(mount, merged.staleRewriteCleared);
2926
+ if (merged.kind === "stale_rewrite") {
2927
+ recordStaleRewrite(mount, merged.paths);
2928
+ return { kind: "skipped" };
2929
+ }
2930
+ if (merged.kind === "conflict" && mount.processLive?.()) {
2931
+ projectionSkippedIds.add(mount.id);
2932
+ mountSkipReasons[mount.id] = { reason: "projection_conflict_deferred", paths: merged.conflictPaths };
2933
+ const holders = mount.describeHolders?.();
2934
+ process.stderr.write(
2935
+ `[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
2936
+ `
2937
+ );
2938
+ return { kind: "skipped" };
2939
+ }
2940
+ mergedProjectionBases.set(mount.id, {
2941
+ projectedCommit: merged.oursCommit,
2942
+ projectedFiles: merged.projectedFiles,
2943
+ readAtMs: merged.readAtMs
2944
+ });
2945
+ if (merged.kind === "conflict") {
2946
+ const refs = snapshotConflict({ workspacePath, attemptId, localHead: merged.oursCommit, remoteHead: currentHead });
2947
+ return {
2948
+ kind: "conflict",
2949
+ result: {
2950
+ outcome: "conflict_blocked",
2951
+ startingHead,
2952
+ localHead: merged.oursCommit,
2953
+ remoteHead: initial.remoteHead,
2954
+ publishedHead: initial.remoteHead,
2955
+ rebaseCount: 0,
2956
+ diffSizeBytes: 0,
2957
+ affectedPaths: merged.conflictPaths,
2958
+ activeMountIds: [],
2959
+ skippedMountIds: input.mounts.map(({ id }) => id).sort(),
2960
+ conflictPaths: merged.conflictPaths,
2961
+ conflictSnapshotRefs: refs,
2962
+ conflictKind: "projection_merge",
2963
+ ...verifiedAncestorHeads ? { verifiedAncestorHeads } : {},
2964
+ error: merged.error
2965
+ }
2966
+ };
2967
+ }
2968
+ mergedProjectionTrees.set(mount.id, merged.resultTree);
2969
+ mergedProjectionMounts.push(mount);
2970
+ if (merged.ledgerMergedPaths.length > 0) {
2971
+ forceHydrationMountIds.add(mount.id);
2972
+ ledgerMergedPathsByMount.set(mount.id, merged.ledgerMergedPaths);
2973
+ }
2974
+ return { kind: "projected" };
2975
+ };
2861
2976
  if (!input.skipMountMirror) {
2862
2977
  const mergeProjectionEnabled = process.env.R5D_WORKSPACE_MERGE_PROJECTION !== "0";
2863
2978
  const mergeSupport = mergeProjectionEnabled ? workspaceMergeProjectionSupport() : { supported: false, error: "Workspace merge projection is disabled by R5D_WORKSPACE_MERGE_PROJECTION=0" };
2979
+ const activeProjectionIds = new Set(selected.active.map(({ id }) => id));
2864
2980
  for (const mount of [...selected.active, ...selected.tombstones]) {
2865
2981
  if (deferredMountIds.has(mount.id)) continue;
2866
2982
  const basisHead = workspaceHydrationReceiptMountBasisHead(projectionBasisReceipt, mount);
2867
- if (projectionBasisHead === null || basisHead === projectionBasisHead) {
2983
+ if (projectionBasisHead === null) {
2868
2984
  fastProjectionMounts.push(mount);
2869
2985
  continue;
2870
2986
  }
2987
+ if (basisHead === projectionBasisHead) {
2988
+ const hydrationLedger = activeProjectionIds.has(mount.id) ? hydrationLedgerFor(mount) : void 0;
2989
+ if (!hydrationLedger || !Object.values(hydrationLedger).some(({ processLiveAcrossHydration }) => processLiveAcrossHydration) || !mergeSupport.supported) {
2990
+ fastProjectionMounts.push(mount);
2991
+ continue;
2992
+ }
2993
+ if (cycleSkippedMountIds.has(mount.id) || mountBusy(mount)) {
2994
+ cycleSkippedMountIds.add(mount.id);
2995
+ continue;
2996
+ }
2997
+ const releaseCheckHold = input.hydrationHooks?.holdMounts([mount.id], `hydration ledger check of mount ${mount.id}`);
2998
+ let checked;
2999
+ try {
3000
+ checked = await workspaceFilesystemExecutor().run("projection_mirror", {
3001
+ workspacePath,
3002
+ mount: workspaceGitMountData(mount),
3003
+ hydrationLedger,
3004
+ checkOnly: true
3005
+ });
3006
+ } finally {
3007
+ releaseCheckHold?.();
3008
+ }
3009
+ if (checked.staleRewriteCleared.length > 0) staleRewriteLedger.forget(mount, checked.staleRewriteCleared);
3010
+ if (checked.staleRewritePaths.length > 0) {
3011
+ recordStaleRewrite(mount, checked.staleRewritePaths);
3012
+ continue;
3013
+ }
3014
+ if (checked.ledgerMergePaths.length === 0) {
3015
+ fastProjectionMounts.push(mount);
3016
+ continue;
3017
+ }
3018
+ if (cycleSkippedMountIds.has(mount.id) || mountBusy(mount)) {
3019
+ cycleSkippedMountIds.add(mount.id);
3020
+ continue;
3021
+ }
3022
+ const outcome2 = await mergeProjectMount(mount, projectionBasisHead, projectionBasisHead);
3023
+ if (outcome2.kind === "conflict") return outcome2.result;
3024
+ continue;
3025
+ }
2871
3026
  if (basisHead === null) {
2872
3027
  deferredMountIds.add(mount.id);
2873
3028
  continue;
@@ -2877,62 +3032,12 @@ async function runWorkspaceGitSynchronization(input, remoteHeadFetches) {
2877
3032
  projectionWarning = mergeSupport.error ?? "Git 2.40 or newer is required for stale workspace mount projection";
2878
3033
  continue;
2879
3034
  }
2880
- if (cycleSkippedMountIds.has(mount.id) || mount.busy?.()) {
3035
+ if (cycleSkippedMountIds.has(mount.id) || mountBusy(mount)) {
2881
3036
  cycleSkippedMountIds.add(mount.id);
2882
3037
  continue;
2883
3038
  }
2884
- const releaseMergeHold = input.hydrationHooks?.holdMounts([mount.id], `merge projection of mount ${mount.id}`);
2885
- let merged;
2886
- try {
2887
- const staleRewriteBlobs = staleRewriteBlobsFor(mount);
2888
- merged = await workspaceFilesystemExecutor().run("projection_merge", {
2889
- workspacePath,
2890
- mount: {
2891
- id: mount.id,
2892
- sourcePath: mount.sourcePath,
2893
- workspaceRelativePath: normalizedWorkspaceMountPath(mount.workspaceRelativePath),
2894
- sourceMode: mount.sourceMode
2895
- },
2896
- basisHead,
2897
- currentHead: projectionBasisHead,
2898
- attemptId,
2899
- ...staleRewriteBlobs ? { staleRewriteBlobs } : {}
2900
- });
2901
- } finally {
2902
- releaseMergeHold?.();
2903
- }
2904
- if (merged.staleRewriteCleared.length > 0) staleRewriteLedger.forget(mount, merged.staleRewriteCleared);
2905
- if (merged.kind === "stale_rewrite") {
2906
- recordStaleRewrite(mount, merged.paths);
2907
- continue;
2908
- }
2909
- mergedProjectionBases.set(mount.id, {
2910
- projectedCommit: merged.oursCommit,
2911
- projectedFiles: merged.projectedFiles,
2912
- readAtMs: merged.readAtMs
2913
- });
2914
- if (merged.kind === "conflict") {
2915
- const refs = snapshotConflict({ workspacePath, attemptId, localHead: merged.oursCommit, remoteHead: projectionBasisHead });
2916
- return {
2917
- outcome: "conflict_blocked",
2918
- startingHead,
2919
- localHead: merged.oursCommit,
2920
- remoteHead: initial.remoteHead,
2921
- publishedHead: initial.remoteHead,
2922
- rebaseCount: 0,
2923
- diffSizeBytes: 0,
2924
- affectedPaths: merged.conflictPaths,
2925
- activeMountIds: [],
2926
- skippedMountIds: input.mounts.map(({ id }) => id).sort(),
2927
- conflictPaths: merged.conflictPaths,
2928
- conflictSnapshotRefs: refs,
2929
- conflictKind: "projection_merge",
2930
- ...verifiedAncestorHeads ? { verifiedAncestorHeads } : {},
2931
- error: merged.error
2932
- };
2933
- }
2934
- mergedProjectionTrees.set(mount.id, merged.resultTree);
2935
- mergedProjectionMounts.push(mount);
3039
+ const outcome = await mergeProjectMount(mount, basisHead, projectionBasisHead);
3040
+ if (outcome.kind === "conflict") return outcome.result;
2936
3041
  }
2937
3042
  }
2938
3043
  if (projectionWarning) process.stderr.write(`[r5d-worker] ${projectionWarning}
@@ -2942,7 +3047,7 @@ async function runWorkspaceGitSynchronization(input, remoteHeadFetches) {
2942
3047
  const completionReceiptMounts = configuredWorkspaceHydrationBasisMounts(workspacePath, input.mounts);
2943
3048
  const currentReceiptBeforeHydration = await readHydratedWorkspaceReceiptAsync(workspacePath);
2944
3049
  refreshCycleSkippedMounts(completionReceiptMounts);
2945
- const mountIsAlreadyCovered = (mount) => Boolean(
3050
+ const mountIsAlreadyCovered = (mount) => !forceHydrationMountIds.has(mount.id) && Boolean(
2946
3051
  currentHeadBeforeHydration && workspaceHydrationReceiptCoversMount(currentReceiptBeforeHydration, currentHeadBeforeHydration, mount)
2947
3052
  );
2948
3053
  if (currentHeadBeforeHydration && completionReceiptMounts.every((mount) => cycleSkippedMountIds.has(mount.id) || mountIsAlreadyCovered(mount))) {
@@ -2963,6 +3068,7 @@ async function runWorkspaceGitSynchronization(input, remoteHeadFetches) {
2963
3068
  currentHeadBeforeHydration,
2964
3069
  uncoveredCompletionMounts
2965
3070
  ) : /* @__PURE__ */ new Set();
3071
+ for (const id of forceHydrationMountIds) unchangedMountIds.delete(id);
2966
3072
  const hydrationMounts = uncoveredCompletionMounts.filter(({ id }) => !unchangedMountIds.has(id));
2967
3073
  const mergeBases = /* @__PURE__ */ new Map();
2968
3074
  if (workspaceMergeHydrationEnabled()) {
@@ -2980,9 +3086,19 @@ async function runWorkspaceGitSynchronization(input, remoteHeadFetches) {
2980
3086
  requiredMounts: uncoveredRequiredLiveMounts.filter(({ id }) => !unchangedMountIds.has(id)),
2981
3087
  recordCurrentHead: true,
2982
3088
  hydrationHooks: input.hydrationHooks,
2983
- ...mergeBases.size > 0 ? { mergeBases } : {}
3089
+ ...mergeBases.size > 0 ? { mergeBases } : {},
3090
+ ...ledgerMergedPathsByMount.size > 0 ? { ledgerMergedPaths: ledgerMergedPathsByMount } : {}
2984
3091
  });
2985
3092
  for (const id of hydration.skippedMountIds) cycleSkippedMountIds.add(id);
3093
+ for (const mount of hydrationMounts) {
3094
+ const mergedPaths = ledgerMergedPathsByMount.get(mount.id);
3095
+ if (!mergedPaths || !hydration.hydratedMountIds.includes(mount.id)) continue;
3096
+ const written = new Set(hydration.hydratedEntryPaths[mount.id] ?? []);
3097
+ const settled = mergedPaths.filter((relativePath) => !written.has(relativePath));
3098
+ if (settled.length > 0) staleRewriteLedger.forget(mount, settled);
3099
+ ledgerMergedPathsByMount.delete(mount.id);
3100
+ forceHydrationMountIds.delete(mount.id);
3101
+ }
2986
3102
  if (currentHeadBeforeHydration) {
2987
3103
  for (const id of hydration.hydratedMountIds) {
2988
3104
  if (mergeBases.has(id)) projectedMountBases.set(id, { projectedCommit: currentHeadBeforeHydration, projectedFiles: null });
@@ -3007,7 +3123,7 @@ async function runWorkspaceGitSynchronization(input, remoteHeadFetches) {
3007
3123
  const receipt = await readHydratedWorkspaceReceiptAsync(workspacePath);
3008
3124
  const transactionSkippedMountIds = new Set(hydration.skippedMountIds);
3009
3125
  const idleMounts = completionReceiptMounts.filter(
3010
- (mount) => !cycleSkippedMountIds.has(mount.id) && !transactionSkippedMountIds.has(mount.id) && !mount.busy?.() && !projectionSkippedIds.has(mount.id)
3126
+ (mount) => !cycleSkippedMountIds.has(mount.id) && !transactionSkippedMountIds.has(mount.id) && !mountBusy(mount) && !projectionSkippedIds.has(mount.id)
3011
3127
  );
3012
3128
  if (currentHead && !idleMounts.every((mount) => workspaceHydrationReceiptCoversMount(receipt, currentHead, mount))) {
3013
3129
  throw new Error(`Workspace inbound integration ${currentHead} could not be fully hydrated before publication continued`);
@@ -3049,7 +3165,7 @@ async function runWorkspaceGitSynchronization(input, remoteHeadFetches) {
3049
3165
  const fastActiveMounts = fastProjectionMounts.filter(({ id }) => selectedActiveIds.has(id));
3050
3166
  const fastTombstones = fastProjectionMounts.filter(({ id }) => !selectedActiveIds.has(id));
3051
3167
  for (const mount of fastActiveMounts) {
3052
- if (cycleSkippedMountIds.has(mount.id) || mount.busy?.()) {
3168
+ if (cycleSkippedMountIds.has(mount.id) || mountBusy(mount)) {
3053
3169
  cycleSkippedMountIds.add(mount.id);
3054
3170
  continue;
3055
3171
  }
@@ -3059,22 +3175,33 @@ async function runWorkspaceGitSynchronization(input, remoteHeadFetches) {
3059
3175
  }
3060
3176
  const releaseMirrorHold = input.hydrationHooks?.holdMounts([mount.id], `projection of mount ${mount.id}`);
3061
3177
  let staleRewritePaths;
3178
+ let ledgerMergePaths;
3062
3179
  try {
3063
- const staleRewriteBlobs = staleRewriteBlobsFor(mount);
3180
+ const hydrationLedger = hydrationLedgerFor(mount);
3064
3181
  const mirrored = await workspaceFilesystemExecutor().run("projection_mirror", {
3065
3182
  workspacePath,
3066
3183
  mount: workspaceGitMountData(mount),
3067
- ...staleRewriteBlobs ? { staleRewriteBlobs } : {}
3184
+ ...hydrationLedger ? { hydrationLedger } : {}
3068
3185
  });
3069
3186
  staleRewritePaths = mirrored.staleRewritePaths;
3187
+ ledgerMergePaths = mirrored.ledgerMergePaths;
3070
3188
  if (mirrored.staleRewriteCleared.length > 0) staleRewriteLedger.forget(mount, mirrored.staleRewriteCleared);
3071
- if (staleRewritePaths.length === 0) {
3189
+ if (staleRewritePaths.length === 0 && ledgerMergePaths.length === 0) {
3072
3190
  projectedGitlinks.push({ mount, gitlinks: mirrored.gitlinks });
3073
3191
  projectedMountFiles.set(mount.id, { files: mirrored.projectedFiles, readAtMs: mirrored.readAtMs });
3074
3192
  }
3075
3193
  } finally {
3076
3194
  releaseMirrorHold?.();
3077
3195
  }
3196
+ if (ledgerMergePaths.length > 0) {
3197
+ projectionSkippedIds.add(mount.id);
3198
+ mountSkipReasons[mount.id] = { reason: "hydration_target_changed", paths: ledgerMergePaths };
3199
+ process.stderr.write(
3200
+ `[r5d-worker] projection of mount ${mount.id} skipped: a running process rewrote ${describeHydrationSkipPaths(mount.sourcePath, ledgerMergePaths)} while the cycle prepared the mirror; the next cycle merges it
3201
+ `
3202
+ );
3203
+ continue;
3204
+ }
3078
3205
  if (staleRewritePaths.length > 0) {
3079
3206
  recordStaleRewrite(mount, staleRewritePaths);
3080
3207
  continue;
@@ -3296,42 +3423,59 @@ ${push.stdout}`)) {
3296
3423
  }
3297
3424
  throw new Error(`Workspace push did not converge after ${maxPushAttempts} attempts`);
3298
3425
  }
3299
- function detectStaleRewrites(workspacePath, mount, staleRewriteBlobs) {
3426
+ function inspectHydrationLedger(workspacePath, mount, hydrationLedger) {
3300
3427
  const outerRoot = workspaceMountOuterPath(workspacePath, mount);
3301
3428
  const stale = [];
3429
+ const merge = [];
3302
3430
  const cleared = [];
3303
- for (const [relativePath, blob] of Object.entries(staleRewriteBlobs)) {
3304
- const algorithm = gitObjectHashAlgorithmFor(blob);
3431
+ for (const [relativePath, entry] of Object.entries(hydrationLedger)) {
3432
+ const algorithm = gitObjectHashAlgorithmFor(entry.preBlob);
3305
3433
  if (!algorithm) continue;
3306
3434
  const source = inspectWorkingTreePath(mount.sourcePath, relativePath);
3307
3435
  if (source.kind !== "entry" || !source.stat.isFile()) {
3308
- cleared.push(relativePath);
3436
+ if (entry.postBlob !== null) cleared.push(relativePath);
3309
3437
  continue;
3310
3438
  }
3439
+ if (entry.stat && projectedFileEntryMatches(entry.stat, source.stat)) continue;
3311
3440
  const content = fs.readFileSync(source.absolutePath);
3441
+ const hash = gitBlobHash(content, algorithm);
3442
+ if (hash === entry.postBlob) continue;
3312
3443
  const outer = inspectWorkingTreePath(outerRoot, relativePath);
3313
3444
  const outerEqual = outer.kind === "entry" && outer.stat.isFile() && fs.readFileSync(outer.absolutePath).equals(content);
3314
- if (gitBlobHash(content, algorithm) !== blob) {
3315
- if (!outerEqual) cleared.push(relativePath);
3445
+ if (outerEqual) {
3446
+ cleared.push(relativePath);
3316
3447
  continue;
3317
3448
  }
3318
- if (outerEqual) {
3449
+ if (entry.processLiveAcrossHydration) {
3450
+ merge.push(relativePath);
3451
+ continue;
3452
+ }
3453
+ if (hash !== entry.preBlob) {
3319
3454
  cleared.push(relativePath);
3320
3455
  continue;
3321
3456
  }
3322
3457
  stale.push(relativePath);
3323
3458
  }
3324
- return { stale: stale.sort(), cleared: cleared.sort() };
3459
+ return { stale: stale.sort(), merge: merge.sort(), cleared: cleared.sort() };
3325
3460
  }
3326
3461
  function runProjectionMirrorJob(input) {
3327
3462
  const workspacePath = path.resolve(input.workspacePath);
3328
3463
  let staleRewriteCleared = [];
3329
- if (input.staleRewriteBlobs) {
3330
- const detected = detectStaleRewrites(workspacePath, input.mount, input.staleRewriteBlobs);
3331
- staleRewriteCleared = detected.cleared;
3332
- if (detected.stale.length > 0) {
3333
- return { gitlinks: [], projectedFiles: {}, readAtMs: Date.now(), staleRewritePaths: detected.stale, staleRewriteCleared };
3464
+ if (input.hydrationLedger) {
3465
+ const inspected = inspectHydrationLedger(workspacePath, input.mount, input.hydrationLedger);
3466
+ staleRewriteCleared = inspected.cleared;
3467
+ if (inspected.stale.length > 0 || inspected.merge.length > 0 || input.checkOnly) {
3468
+ return {
3469
+ gitlinks: [],
3470
+ projectedFiles: {},
3471
+ readAtMs: Date.now(),
3472
+ staleRewritePaths: inspected.stale,
3473
+ ledgerMergePaths: inspected.merge,
3474
+ staleRewriteCleared
3475
+ };
3334
3476
  }
3477
+ } else if (input.checkOnly) {
3478
+ return { gitlinks: [], projectedFiles: {}, readAtMs: Date.now(), staleRewritePaths: [], ledgerMergePaths: [], staleRewriteCleared };
3335
3479
  }
3336
3480
  const projectedFiles = /* @__PURE__ */ new Map();
3337
3481
  const projected = mirrorMountsToWorkspace(workspacePath, [input.mount], projectedFiles);
@@ -3340,6 +3484,7 @@ function runProjectionMirrorJob(input) {
3340
3484
  projectedFiles: Object.fromEntries(projectedFiles),
3341
3485
  readAtMs: Date.now(),
3342
3486
  staleRewritePaths: [],
3487
+ ledgerMergePaths: [],
3343
3488
  staleRewriteCleared
3344
3489
  };
3345
3490
  }
@@ -3379,6 +3524,7 @@ export {
3379
3524
  WORKSPACE_GIT_HYDRATION_RECOVERIES,
3380
3525
  WORKSPACE_GIT_HYDRATION_TRANSACTION,
3381
3526
  WORKSPACE_MERGE_HYDRATION_SWITCH,
3527
+ WORKSPACE_PROCESS_BUSY_FENCE_SWITCH,
3382
3528
  WorkspaceHydrationRecoveryRequiredError,
3383
3529
  WorkspaceRemediationAncestryError,
3384
3530
  configureExistingWorkspaceGitForRemediation,