@ricsam/r5d-worker 0.0.99 → 0.0.100

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
@@ -97,9 +97,35 @@ function workerServerHeartbeatAction(input) {
97
97
  if (input.watchdogSweepDelayed || input.probeElapsedMs === void 0) return "start_probe";
98
98
  return input.probeElapsedMs >= input.timeoutMs ? "terminate" : "wait";
99
99
  }
100
+ const WORKSPACE_HYDRATION_LEASE_OPERATION_PREFIX = "hydration:";
101
+ function createWorkspaceHydrationLeaseHooks(send, options = {}) {
102
+ const yieldToEventLoop = options.yieldToEventLoop ?? (() => new Promise((resolve) => setImmediate(resolve)));
103
+ const log = options.log ?? ((line) => process.stdout.write(`${line}
104
+ `));
105
+ const now = options.now ?? (() => performance.now());
106
+ const operationId = (context) => `${WORKSPACE_HYDRATION_LEASE_OPERATION_PREFIX}${context.transactionId}`;
107
+ const startedAtMs = /* @__PURE__ */ new Map();
108
+ return {
109
+ async beforeTransaction(context) {
110
+ send({ type: "heartbeat_lease", operationId: operationId(context), kind: "hydration", active: true });
111
+ startedAtMs.set(context.transactionId, now());
112
+ log(`[r5d-worker] hydrating ${context.mountIds.length} workspace mount(s) under hydration lease ${context.transactionId}`);
113
+ await yieldToEventLoop();
114
+ },
115
+ afterTransaction(context) {
116
+ const startedAt = startedAtMs.get(context.transactionId);
117
+ startedAtMs.delete(context.transactionId);
118
+ send({ type: "heartbeat_lease", operationId: operationId(context), kind: "hydration", active: false });
119
+ log(
120
+ `[r5d-worker] released hydration lease ${context.transactionId} after ${startedAt === void 0 ? "?" : Math.round(now() - startedAt)}ms`
121
+ );
122
+ }
123
+ };
124
+ }
100
125
  const workerLifecycleTestHarness = {
101
126
  boundedWorkerLifecycleProgress,
102
- workerServerHeartbeatAction
127
+ workerServerHeartbeatAction,
128
+ createWorkspaceHydrationLeaseHooks
103
129
  };
104
130
  class ProjectWorkspaceConfigurationDeferredError extends Error {
105
131
  }
@@ -4370,6 +4396,7 @@ async function startWorker(options) {
4370
4396
  error: error instanceof Error ? error.message : String(error)
4371
4397
  };
4372
4398
  };
4399
+ const workspaceHydrationTransactionHooks = createWorkspaceHydrationLeaseHooks((message) => sendWorkerMessageFromCurrentSource(ws, message));
4373
4400
  const performWorkspaceSync = async (input) => {
4374
4401
  if (!workspaceRemoteUrl || !workspaceCredentialHelper || !workspaceCredentialUsername || !workspaceGitIdentity) {
4375
4402
  throw new Error("Worker workspace configuration has not been received");
@@ -4392,7 +4419,8 @@ async function startWorker(options) {
4392
4419
  credentialHelper: workspaceCredentialHelper,
4393
4420
  credentialUsername: workspaceCredentialUsername,
4394
4421
  gitIdentity: workspaceGitIdentity,
4395
- mounts: configuredMounts
4422
+ mounts: configuredMounts,
4423
+ hydrationHooks: workspaceHydrationTransactionHooks
4396
4424
  });
4397
4425
  await applyObservedProjectHeadsAfterInboundWorkspace(observedProjectHeads2, reset.activeMountIds, true);
4398
4426
  return {
@@ -4446,7 +4474,8 @@ async function startWorker(options) {
4446
4474
  assertStillAdmitted: input.assertStillAdmitted,
4447
4475
  afterWorkspacePublished: outerRemediation ? void 0 : async ({ activeMountIds, publishedHead }) => {
4448
4476
  await pushChangedProjectHeads(activeMountIds, publishedHead, publicationHeads, expectedMirrorHeads, inboundMoveMountIds);
4449
- }
4477
+ },
4478
+ hydrationHooks: workspaceHydrationTransactionHooks
4450
4479
  });
4451
4480
  input.assertStillAdmitted?.();
4452
4481
  if (result.initialPublicationMountIds && result.initialPublicationMountIds.length > 0) {
@@ -5040,7 +5069,10 @@ async function startWorker(options) {
5040
5069
  sourcePath: recoverySourceOverrides.get(mount.id) ?? mount.sourcePath,
5041
5070
  busy: mount.busyForRecovery ?? mount.busy
5042
5071
  }));
5043
- await (0, import_workspace_git_sync.recoverWorkspaceGitHydration)(workspaceShadowRoot, recoveryMounts, { preserveStaleBases: true });
5072
+ await (0, import_workspace_git_sync.recoverWorkspaceGitHydration)(workspaceShadowRoot, recoveryMounts, {
5073
+ preserveStaleBases: true,
5074
+ hydrationHooks: workspaceHydrationTransactionHooks
5075
+ });
5044
5076
  for (const project of message.projects) {
5045
5077
  for (const { branchName } of project.preserveOnlyBranches) {
5046
5078
  if (stillPendingCreatedBranches.has((0, import_workspace_automatic_sync_policy.pendingCreatedBranchKey)(project.projectId, branchName))) {
@@ -5084,7 +5116,7 @@ async function startWorker(options) {
5084
5116
  // layouts can be marked ready. No process can resolve an unready
5085
5117
  // target, and preserving the fetched outer tree here prevents clean
5086
5118
  // checkout initialization from erasing synchronized dirt.
5087
- { ignoreBusy: true }
5119
+ { ignoreBusy: true, hydrationHooks: workspaceHydrationTransactionHooks }
5088
5120
  );
5089
5121
  await ensureConfiguredProjects((progress) => {
5090
5122
  sendLifecycleProgress({ phase: "configuring", operationId: configurationOperationId, ...progress });
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.99",
3
+ "version": "0.0.100",
4
4
  "type": "commonjs"
5
5
  }
@@ -1601,56 +1601,67 @@ async function hydrateWorkspaceGitMountsTransactionally(input) {
1601
1601
  const busyMountIds = new Set(input.ignoreBusy ? [] : candidateMounts.filter((mount) => mount.busy?.()).map(({ id }) => id));
1602
1602
  const requestedIds = new Set(input.mounts.map(({ id }) => id));
1603
1603
  const requiredIds = new Set(requiredMounts.map(({ id }) => id));
1604
- const hydrationMounts2 = input.mounts.filter(({ id }) => !busyMountIds.has(id));
1605
- const advancedDurabilityMounts2 = durabilityMounts.filter(
1604
+ const hydrationMounts = input.mounts.filter(({ id }) => !busyMountIds.has(id));
1605
+ const advancedDurabilityMounts = durabilityMounts.filter(
1606
1606
  ({ id }) => !busyMountIds.has(id) && (requestedIds.has(id) || !requiredIds.has(id))
1607
1607
  );
1608
1608
  const unavailableRequiredIds = requiredMounts.filter(({ id }) => busyMountIds.has(id) || !requestedIds.has(id)).map(({ id }) => id);
1609
1609
  const carriedMounts = carryMounts.filter(({ id }) => !busyMountIds.has(id));
1610
1610
  return {
1611
1611
  busyMountIds,
1612
- hydrationMounts: hydrationMounts2,
1613
- advancedDurabilityMounts: advancedDurabilityMounts2,
1612
+ hydrationMounts,
1613
+ advancedDurabilityMounts,
1614
1614
  skippedMountIds: [.../* @__PURE__ */ new Set([...busyMountIds, ...unavailableRequiredIds])].sort(),
1615
- targetReceipt: targetHead ? receiptWithMountsAtHead(receiptBeforeTransaction, targetHead, [...advancedDurabilityMounts2, ...carriedMounts], receiptMounts) : null
1615
+ targetReceipt: targetHead ? receiptWithMountsAtHead(receiptBeforeTransaction, targetHead, [...advancedDurabilityMounts, ...carriedMounts], receiptMounts) : null
1616
1616
  };
1617
1617
  };
1618
1618
  const noopCandidate = computeSelection();
1619
1619
  if (noopCandidate.hydrationMounts.length === 0 && noopCandidate.advancedDurabilityMounts.length === 0 && noopCandidate.targetReceipt && lstatIfExists(hydrationTransactionPath(workspacePath)) === null && workspaceHydrationReceiptsEqual(receiptBeforeTransaction, noopCandidate.targetReceipt) && hydratedWorkspaceReceiptFileIsV3(workspacePath) && await workspaceBasisRefsAreCurrentAsync(workspacePath, noopCandidate.targetReceipt)) {
1620
1620
  return { hydratedMountIds: [], skippedMountIds: noopCandidate.skippedMountIds };
1621
1621
  }
1622
- const selection = computeSelection();
1623
- const { hydrationMounts, advancedDurabilityMounts, targetReceipt } = selection;
1624
- const { manifest, durabilityScopes } = beginHydrationTransaction(workspacePath, hydrationMounts, targetReceipt);
1622
+ const hooks = input.hydrationHooks;
1623
+ const context = {
1624
+ transactionId: crypto.randomUUID(),
1625
+ workspacePath,
1626
+ mountIds: [...new Set([...noopCandidate.hydrationMounts, ...noopCandidate.advancedDurabilityMounts].map(({ id }) => id))].sort()
1627
+ };
1628
+ if (hooks) await hooks.beforeTransaction(context);
1625
1629
  try {
1626
- const hydration = hydrateWorkspaceGitMountsRaw(workspacePath, hydrationMounts, { ignoreBusy: true });
1627
- const expectedHydratedIds = hydrationMounts.map(({ id }) => id).sort();
1628
- if (hydration.skippedMountIds.length > 0 || hydration.hydratedMountIds.length !== expectedHydratedIds.length || hydration.hydratedMountIds.some((id, index) => id !== expectedHydratedIds[index])) {
1629
- throw new Error("Workspace hydration did not include every required mount");
1630
- }
1631
- fsyncHydratedWorkspaceMounts(advancedDurabilityMounts, durabilityScopes);
1632
- markHydrationTransactionDurable(workspacePath);
1633
- if (targetReceipt) updateHydratedWorkspaceReceipt(workspacePath, targetReceipt);
1634
- removeHydrationTransaction(workspacePath);
1635
- return {
1636
- hydratedMountIds: hydration.hydratedMountIds,
1637
- skippedMountIds: selection.skippedMountIds
1638
- };
1639
- } catch (error) {
1640
- const transactionPath = hydrationTransactionPath(workspacePath);
1641
- if (lstatIfExists(transactionPath) && hydrationTransactionCommitted(transactionPath)) {
1642
- throw error;
1643
- }
1630
+ const selection = computeSelection();
1631
+ const { hydrationMounts, advancedDurabilityMounts, targetReceipt } = selection;
1632
+ const { manifest, durabilityScopes } = beginHydrationTransaction(workspacePath, hydrationMounts, targetReceipt);
1644
1633
  try {
1645
- restoreHydrationTransaction(workspacePath, manifest);
1634
+ const hydration = hydrateWorkspaceGitMountsRaw(workspacePath, hydrationMounts, { ignoreBusy: true });
1635
+ const expectedHydratedIds = hydrationMounts.map(({ id }) => id).sort();
1636
+ if (hydration.skippedMountIds.length > 0 || hydration.hydratedMountIds.length !== expectedHydratedIds.length || hydration.hydratedMountIds.some((id, index) => id !== expectedHydratedIds[index])) {
1637
+ throw new Error("Workspace hydration did not include every required mount");
1638
+ }
1639
+ fsyncHydratedWorkspaceMounts(advancedDurabilityMounts, durabilityScopes);
1640
+ markHydrationTransactionDurable(workspacePath);
1641
+ if (targetReceipt) updateHydratedWorkspaceReceipt(workspacePath, targetReceipt);
1646
1642
  removeHydrationTransaction(workspacePath);
1647
- } catch (restoreError) {
1648
- throw new AggregateError(
1649
- [error, restoreError],
1650
- `Workspace hydration failed and its durable snapshot could not be restored: ${error instanceof Error ? error.message : String(error)}`
1651
- );
1643
+ return {
1644
+ hydratedMountIds: hydration.hydratedMountIds,
1645
+ skippedMountIds: selection.skippedMountIds
1646
+ };
1647
+ } catch (error) {
1648
+ const transactionPath = hydrationTransactionPath(workspacePath);
1649
+ if (lstatIfExists(transactionPath) && hydrationTransactionCommitted(transactionPath)) {
1650
+ throw error;
1651
+ }
1652
+ try {
1653
+ restoreHydrationTransaction(workspacePath, manifest);
1654
+ removeHydrationTransaction(workspacePath);
1655
+ } catch (restoreError) {
1656
+ throw new AggregateError(
1657
+ [error, restoreError],
1658
+ `Workspace hydration failed and its durable snapshot could not be restored: ${error instanceof Error ? error.message : String(error)}`
1659
+ );
1660
+ }
1661
+ throw error;
1652
1662
  }
1653
- throw error;
1663
+ } finally {
1664
+ hooks?.afterTransaction(context);
1654
1665
  }
1655
1666
  }
1656
1667
  async function hydrateWorkspaceGitMounts(workspacePath, mounts, options = {}) {
@@ -1662,7 +1673,8 @@ async function hydrateWorkspaceGitMounts(workspacePath, mounts, options = {}) {
1662
1673
  return hydrateWorkspaceGitMountsTransactionally({
1663
1674
  workspacePath,
1664
1675
  mounts,
1665
- ignoreBusy: options.ignoreBusy
1676
+ ignoreBusy: options.ignoreBusy,
1677
+ hydrationHooks: options.hydrationHooks
1666
1678
  });
1667
1679
  }
1668
1680
  async function recoverWorkspaceGitHydration(workspacePath, mounts, options = {}) {
@@ -1702,7 +1714,8 @@ async function recoverWorkspaceGitHydration(workspacePath, mounts, options = {})
1702
1714
  receiptMounts: configuredBasisMounts,
1703
1715
  requiredMounts: hydrationTargets,
1704
1716
  ignoreBusy: options.ignoreBusy,
1705
- recordCurrentHead: true
1717
+ recordCurrentHead: true,
1718
+ hydrationHooks: options.hydrationHooks
1706
1719
  });
1707
1720
  void hydration;
1708
1721
  }
@@ -1753,7 +1766,8 @@ async function resetWorkspaceGit(input) {
1753
1766
  durabilityMounts: [...requiredLiveMounts, ...selected.tombstones, ...durablyAbsentMounts],
1754
1767
  receiptMounts: input.mounts,
1755
1768
  requiredMounts: requiredLiveMounts,
1756
- recordCurrentHead: true
1769
+ recordCurrentHead: true,
1770
+ hydrationHooks: input.hydrationHooks
1757
1771
  });
1758
1772
  const requiredLiveIds = requiredLiveMounts.map(({ id }) => id).sort();
1759
1773
  const resetHead = revParse(workspacePath, "HEAD");
@@ -2008,7 +2022,8 @@ async function synchronizeWorkspaceGit(input) {
2008
2022
  await recoverWorkspaceGitHydration(workspacePath, input.mounts, {
2009
2023
  ...deferredMountIds.size > 0 ? { deferMountIds: deferredMountIds } : {},
2010
2024
  preserveResolutionInProgress,
2011
- preserveStaleBases: true
2025
+ preserveStaleBases: true,
2026
+ hydrationHooks: input.hydrationHooks
2012
2027
  });
2013
2028
  const selected = activeMounts(input.mounts);
2014
2029
  const projectionMutationTokens = new Map(
@@ -2134,7 +2149,8 @@ async function synchronizeWorkspaceGit(input) {
2134
2149
  carryMounts: uncoveredCompletionMounts.filter(({ id }) => unchangedMountIds.has(id)),
2135
2150
  receiptMounts: completionReceiptMounts,
2136
2151
  requiredMounts: uncoveredRequiredLiveMounts.filter(({ id }) => !unchangedMountIds.has(id)),
2137
- recordCurrentHead: true
2152
+ recordCurrentHead: true,
2153
+ hydrationHooks: input.hydrationHooks
2138
2154
  });
2139
2155
  for (const id of hydration.skippedMountIds) cycleSkippedMountIds.add(id);
2140
2156
  refreshCycleSkippedMounts(completionReceiptMounts);
package/dist/mjs/main.mjs CHANGED
@@ -104,9 +104,35 @@ function workerServerHeartbeatAction(input) {
104
104
  if (input.watchdogSweepDelayed || input.probeElapsedMs === void 0) return "start_probe";
105
105
  return input.probeElapsedMs >= input.timeoutMs ? "terminate" : "wait";
106
106
  }
107
+ const WORKSPACE_HYDRATION_LEASE_OPERATION_PREFIX = "hydration:";
108
+ function createWorkspaceHydrationLeaseHooks(send, options = {}) {
109
+ const yieldToEventLoop = options.yieldToEventLoop ?? (() => new Promise((resolve) => setImmediate(resolve)));
110
+ const log = options.log ?? ((line) => process.stdout.write(`${line}
111
+ `));
112
+ const now = options.now ?? (() => performance.now());
113
+ const operationId = (context) => `${WORKSPACE_HYDRATION_LEASE_OPERATION_PREFIX}${context.transactionId}`;
114
+ const startedAtMs = /* @__PURE__ */ new Map();
115
+ return {
116
+ async beforeTransaction(context) {
117
+ send({ type: "heartbeat_lease", operationId: operationId(context), kind: "hydration", active: true });
118
+ startedAtMs.set(context.transactionId, now());
119
+ log(`[r5d-worker] hydrating ${context.mountIds.length} workspace mount(s) under hydration lease ${context.transactionId}`);
120
+ await yieldToEventLoop();
121
+ },
122
+ afterTransaction(context) {
123
+ const startedAt = startedAtMs.get(context.transactionId);
124
+ startedAtMs.delete(context.transactionId);
125
+ send({ type: "heartbeat_lease", operationId: operationId(context), kind: "hydration", active: false });
126
+ log(
127
+ `[r5d-worker] released hydration lease ${context.transactionId} after ${startedAt === void 0 ? "?" : Math.round(now() - startedAt)}ms`
128
+ );
129
+ }
130
+ };
131
+ }
107
132
  const workerLifecycleTestHarness = {
108
133
  boundedWorkerLifecycleProgress,
109
- workerServerHeartbeatAction
134
+ workerServerHeartbeatAction,
135
+ createWorkspaceHydrationLeaseHooks
110
136
  };
111
137
  class ProjectWorkspaceConfigurationDeferredError extends Error {
112
138
  }
@@ -4377,6 +4403,7 @@ async function startWorker(options) {
4377
4403
  error: error instanceof Error ? error.message : String(error)
4378
4404
  };
4379
4405
  };
4406
+ const workspaceHydrationTransactionHooks = createWorkspaceHydrationLeaseHooks((message) => sendWorkerMessageFromCurrentSource(ws, message));
4380
4407
  const performWorkspaceSync = async (input) => {
4381
4408
  if (!workspaceRemoteUrl || !workspaceCredentialHelper || !workspaceCredentialUsername || !workspaceGitIdentity) {
4382
4409
  throw new Error("Worker workspace configuration has not been received");
@@ -4399,7 +4426,8 @@ async function startWorker(options) {
4399
4426
  credentialHelper: workspaceCredentialHelper,
4400
4427
  credentialUsername: workspaceCredentialUsername,
4401
4428
  gitIdentity: workspaceGitIdentity,
4402
- mounts: configuredMounts
4429
+ mounts: configuredMounts,
4430
+ hydrationHooks: workspaceHydrationTransactionHooks
4403
4431
  });
4404
4432
  await applyObservedProjectHeadsAfterInboundWorkspace(observedProjectHeads2, reset.activeMountIds, true);
4405
4433
  return {
@@ -4453,7 +4481,8 @@ async function startWorker(options) {
4453
4481
  assertStillAdmitted: input.assertStillAdmitted,
4454
4482
  afterWorkspacePublished: outerRemediation ? void 0 : async ({ activeMountIds, publishedHead }) => {
4455
4483
  await pushChangedProjectHeads(activeMountIds, publishedHead, publicationHeads, expectedMirrorHeads, inboundMoveMountIds);
4456
- }
4484
+ },
4485
+ hydrationHooks: workspaceHydrationTransactionHooks
4457
4486
  });
4458
4487
  input.assertStillAdmitted?.();
4459
4488
  if (result.initialPublicationMountIds && result.initialPublicationMountIds.length > 0) {
@@ -5047,7 +5076,10 @@ async function startWorker(options) {
5047
5076
  sourcePath: recoverySourceOverrides.get(mount.id) ?? mount.sourcePath,
5048
5077
  busy: mount.busyForRecovery ?? mount.busy
5049
5078
  }));
5050
- await recoverWorkspaceGitHydration(workspaceShadowRoot, recoveryMounts, { preserveStaleBases: true });
5079
+ await recoverWorkspaceGitHydration(workspaceShadowRoot, recoveryMounts, {
5080
+ preserveStaleBases: true,
5081
+ hydrationHooks: workspaceHydrationTransactionHooks
5082
+ });
5051
5083
  for (const project of message.projects) {
5052
5084
  for (const { branchName } of project.preserveOnlyBranches) {
5053
5085
  if (stillPendingCreatedBranches.has(pendingCreatedBranchKey(project.projectId, branchName))) {
@@ -5091,7 +5123,7 @@ async function startWorker(options) {
5091
5123
  // layouts can be marked ready. No process can resolve an unready
5092
5124
  // target, and preserving the fetched outer tree here prevents clean
5093
5125
  // checkout initialization from erasing synchronized dirt.
5094
- { ignoreBusy: true }
5126
+ { ignoreBusy: true, hydrationHooks: workspaceHydrationTransactionHooks }
5095
5127
  );
5096
5128
  await ensureConfiguredProjects((progress) => {
5097
5129
  sendLifecycleProgress({ phase: "configuring", operationId: configurationOperationId, ...progress });
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.99",
3
+ "version": "0.0.100",
4
4
  "type": "module"
5
5
  }
@@ -1564,56 +1564,67 @@ async function hydrateWorkspaceGitMountsTransactionally(input) {
1564
1564
  const busyMountIds = new Set(input.ignoreBusy ? [] : candidateMounts.filter((mount) => mount.busy?.()).map(({ id }) => id));
1565
1565
  const requestedIds = new Set(input.mounts.map(({ id }) => id));
1566
1566
  const requiredIds = new Set(requiredMounts.map(({ id }) => id));
1567
- const hydrationMounts2 = input.mounts.filter(({ id }) => !busyMountIds.has(id));
1568
- const advancedDurabilityMounts2 = durabilityMounts.filter(
1567
+ const hydrationMounts = input.mounts.filter(({ id }) => !busyMountIds.has(id));
1568
+ const advancedDurabilityMounts = durabilityMounts.filter(
1569
1569
  ({ id }) => !busyMountIds.has(id) && (requestedIds.has(id) || !requiredIds.has(id))
1570
1570
  );
1571
1571
  const unavailableRequiredIds = requiredMounts.filter(({ id }) => busyMountIds.has(id) || !requestedIds.has(id)).map(({ id }) => id);
1572
1572
  const carriedMounts = carryMounts.filter(({ id }) => !busyMountIds.has(id));
1573
1573
  return {
1574
1574
  busyMountIds,
1575
- hydrationMounts: hydrationMounts2,
1576
- advancedDurabilityMounts: advancedDurabilityMounts2,
1575
+ hydrationMounts,
1576
+ advancedDurabilityMounts,
1577
1577
  skippedMountIds: [.../* @__PURE__ */ new Set([...busyMountIds, ...unavailableRequiredIds])].sort(),
1578
- targetReceipt: targetHead ? receiptWithMountsAtHead(receiptBeforeTransaction, targetHead, [...advancedDurabilityMounts2, ...carriedMounts], receiptMounts) : null
1578
+ targetReceipt: targetHead ? receiptWithMountsAtHead(receiptBeforeTransaction, targetHead, [...advancedDurabilityMounts, ...carriedMounts], receiptMounts) : null
1579
1579
  };
1580
1580
  };
1581
1581
  const noopCandidate = computeSelection();
1582
1582
  if (noopCandidate.hydrationMounts.length === 0 && noopCandidate.advancedDurabilityMounts.length === 0 && noopCandidate.targetReceipt && lstatIfExists(hydrationTransactionPath(workspacePath)) === null && workspaceHydrationReceiptsEqual(receiptBeforeTransaction, noopCandidate.targetReceipt) && hydratedWorkspaceReceiptFileIsV3(workspacePath) && await workspaceBasisRefsAreCurrentAsync(workspacePath, noopCandidate.targetReceipt)) {
1583
1583
  return { hydratedMountIds: [], skippedMountIds: noopCandidate.skippedMountIds };
1584
1584
  }
1585
- const selection = computeSelection();
1586
- const { hydrationMounts, advancedDurabilityMounts, targetReceipt } = selection;
1587
- const { manifest, durabilityScopes } = beginHydrationTransaction(workspacePath, hydrationMounts, targetReceipt);
1585
+ const hooks = input.hydrationHooks;
1586
+ const context = {
1587
+ transactionId: crypto.randomUUID(),
1588
+ workspacePath,
1589
+ mountIds: [...new Set([...noopCandidate.hydrationMounts, ...noopCandidate.advancedDurabilityMounts].map(({ id }) => id))].sort()
1590
+ };
1591
+ if (hooks) await hooks.beforeTransaction(context);
1588
1592
  try {
1589
- const hydration = hydrateWorkspaceGitMountsRaw(workspacePath, hydrationMounts, { ignoreBusy: true });
1590
- const expectedHydratedIds = hydrationMounts.map(({ id }) => id).sort();
1591
- if (hydration.skippedMountIds.length > 0 || hydration.hydratedMountIds.length !== expectedHydratedIds.length || hydration.hydratedMountIds.some((id, index) => id !== expectedHydratedIds[index])) {
1592
- throw new Error("Workspace hydration did not include every required mount");
1593
- }
1594
- fsyncHydratedWorkspaceMounts(advancedDurabilityMounts, durabilityScopes);
1595
- markHydrationTransactionDurable(workspacePath);
1596
- if (targetReceipt) updateHydratedWorkspaceReceipt(workspacePath, targetReceipt);
1597
- removeHydrationTransaction(workspacePath);
1598
- return {
1599
- hydratedMountIds: hydration.hydratedMountIds,
1600
- skippedMountIds: selection.skippedMountIds
1601
- };
1602
- } catch (error) {
1603
- const transactionPath = hydrationTransactionPath(workspacePath);
1604
- if (lstatIfExists(transactionPath) && hydrationTransactionCommitted(transactionPath)) {
1605
- throw error;
1606
- }
1593
+ const selection = computeSelection();
1594
+ const { hydrationMounts, advancedDurabilityMounts, targetReceipt } = selection;
1595
+ const { manifest, durabilityScopes } = beginHydrationTransaction(workspacePath, hydrationMounts, targetReceipt);
1607
1596
  try {
1608
- restoreHydrationTransaction(workspacePath, manifest);
1597
+ const hydration = hydrateWorkspaceGitMountsRaw(workspacePath, hydrationMounts, { ignoreBusy: true });
1598
+ const expectedHydratedIds = hydrationMounts.map(({ id }) => id).sort();
1599
+ if (hydration.skippedMountIds.length > 0 || hydration.hydratedMountIds.length !== expectedHydratedIds.length || hydration.hydratedMountIds.some((id, index) => id !== expectedHydratedIds[index])) {
1600
+ throw new Error("Workspace hydration did not include every required mount");
1601
+ }
1602
+ fsyncHydratedWorkspaceMounts(advancedDurabilityMounts, durabilityScopes);
1603
+ markHydrationTransactionDurable(workspacePath);
1604
+ if (targetReceipt) updateHydratedWorkspaceReceipt(workspacePath, targetReceipt);
1609
1605
  removeHydrationTransaction(workspacePath);
1610
- } catch (restoreError) {
1611
- throw new AggregateError(
1612
- [error, restoreError],
1613
- `Workspace hydration failed and its durable snapshot could not be restored: ${error instanceof Error ? error.message : String(error)}`
1614
- );
1606
+ return {
1607
+ hydratedMountIds: hydration.hydratedMountIds,
1608
+ skippedMountIds: selection.skippedMountIds
1609
+ };
1610
+ } catch (error) {
1611
+ const transactionPath = hydrationTransactionPath(workspacePath);
1612
+ if (lstatIfExists(transactionPath) && hydrationTransactionCommitted(transactionPath)) {
1613
+ throw error;
1614
+ }
1615
+ try {
1616
+ restoreHydrationTransaction(workspacePath, manifest);
1617
+ removeHydrationTransaction(workspacePath);
1618
+ } catch (restoreError) {
1619
+ throw new AggregateError(
1620
+ [error, restoreError],
1621
+ `Workspace hydration failed and its durable snapshot could not be restored: ${error instanceof Error ? error.message : String(error)}`
1622
+ );
1623
+ }
1624
+ throw error;
1615
1625
  }
1616
- throw error;
1626
+ } finally {
1627
+ hooks?.afterTransaction(context);
1617
1628
  }
1618
1629
  }
1619
1630
  async function hydrateWorkspaceGitMounts(workspacePath, mounts, options = {}) {
@@ -1625,7 +1636,8 @@ async function hydrateWorkspaceGitMounts(workspacePath, mounts, options = {}) {
1625
1636
  return hydrateWorkspaceGitMountsTransactionally({
1626
1637
  workspacePath,
1627
1638
  mounts,
1628
- ignoreBusy: options.ignoreBusy
1639
+ ignoreBusy: options.ignoreBusy,
1640
+ hydrationHooks: options.hydrationHooks
1629
1641
  });
1630
1642
  }
1631
1643
  async function recoverWorkspaceGitHydration(workspacePath, mounts, options = {}) {
@@ -1665,7 +1677,8 @@ async function recoverWorkspaceGitHydration(workspacePath, mounts, options = {})
1665
1677
  receiptMounts: configuredBasisMounts,
1666
1678
  requiredMounts: hydrationTargets,
1667
1679
  ignoreBusy: options.ignoreBusy,
1668
- recordCurrentHead: true
1680
+ recordCurrentHead: true,
1681
+ hydrationHooks: options.hydrationHooks
1669
1682
  });
1670
1683
  void hydration;
1671
1684
  }
@@ -1716,7 +1729,8 @@ async function resetWorkspaceGit(input) {
1716
1729
  durabilityMounts: [...requiredLiveMounts, ...selected.tombstones, ...durablyAbsentMounts],
1717
1730
  receiptMounts: input.mounts,
1718
1731
  requiredMounts: requiredLiveMounts,
1719
- recordCurrentHead: true
1732
+ recordCurrentHead: true,
1733
+ hydrationHooks: input.hydrationHooks
1720
1734
  });
1721
1735
  const requiredLiveIds = requiredLiveMounts.map(({ id }) => id).sort();
1722
1736
  const resetHead = revParse(workspacePath, "HEAD");
@@ -1971,7 +1985,8 @@ async function synchronizeWorkspaceGit(input) {
1971
1985
  await recoverWorkspaceGitHydration(workspacePath, input.mounts, {
1972
1986
  ...deferredMountIds.size > 0 ? { deferMountIds: deferredMountIds } : {},
1973
1987
  preserveResolutionInProgress,
1974
- preserveStaleBases: true
1988
+ preserveStaleBases: true,
1989
+ hydrationHooks: input.hydrationHooks
1975
1990
  });
1976
1991
  const selected = activeMounts(input.mounts);
1977
1992
  const projectionMutationTokens = new Map(
@@ -2097,7 +2112,8 @@ async function synchronizeWorkspaceGit(input) {
2097
2112
  carryMounts: uncoveredCompletionMounts.filter(({ id }) => unchangedMountIds.has(id)),
2098
2113
  receiptMounts: completionReceiptMounts,
2099
2114
  requiredMounts: uncoveredRequiredLiveMounts.filter(({ id }) => !unchangedMountIds.has(id)),
2100
- recordCurrentHead: true
2115
+ recordCurrentHead: true,
2116
+ hydrationHooks: input.hydrationHooks
2101
2117
  });
2102
2118
  for (const id of hydration.skippedMountIds) cycleSkippedMountIds.add(id);
2103
2119
  refreshCycleSkippedMounts(completionReceiptMounts);
@@ -2,6 +2,7 @@
2
2
  import { Database } from "bun:sqlite";
3
3
  import type { WorkerGitIdentity } from "./git-identity";
4
4
  import { configureGitHubRegistryAuthFiles, type PreparedPrivateAuthFileGeneration } from "./registry-auth";
5
+ import { type WorkspaceHydrationTransactionHooks } from "./workspace-git-sync";
5
6
  type WorkerProjectConfig = {
6
7
  projectId: string;
7
8
  checkoutPathSegments: [namespace: string, project: string];
@@ -23,6 +24,11 @@ type WorkerProjectConfig = {
23
24
  baseCommitHash: string;
24
25
  }>;
25
26
  };
27
+ type WorkerAheadOfOriginBranch = {
28
+ projectId: string;
29
+ branchName: string;
30
+ ahead: number;
31
+ };
26
32
  type WorkerLifecycleProgress = {
27
33
  phase: "starting" | "recovering" | "configuring" | "syncing" | "ready" | "failed";
28
34
  operationId: string;
@@ -39,9 +45,28 @@ declare function workerServerHeartbeatAction(input: {
39
45
  timeoutMs: number;
40
46
  watchdogSweepDelayed: boolean;
41
47
  }): "wait" | "start_probe" | "terminate";
48
+ /**
49
+ * A hydration transaction blocks this event loop until it ends, so no pong,
50
+ * progress, or result can leave the process while it runs; on a slow disk
51
+ * that is hours. Declare a hydration heartbeat lease first: the server keeps
52
+ * the control socket open and defers the affected request deadlines while the
53
+ * lease is active instead of reading the silence as a dead worker, which is
54
+ * what turned one slow hydration into a mandatory canonical reset (incident
55
+ * 3bccab03). Bun writes the frame to the socket synchronously; the extra
56
+ * event-loop turn lets control messages that were already queued run before
57
+ * the busy observation that precedes the transaction body.
58
+ */
59
+ declare function createWorkspaceHydrationLeaseHooks(send: (message: Extract<WorkerClientMessage, {
60
+ type: "heartbeat_lease";
61
+ }>) => void, options?: {
62
+ yieldToEventLoop?: () => Promise<void>;
63
+ log?: (line: string) => void;
64
+ now?: () => number;
65
+ }): WorkspaceHydrationTransactionHooks;
42
66
  export declare const workerLifecycleTestHarness: {
43
67
  boundedWorkerLifecycleProgress: typeof boundedWorkerLifecycleProgress;
44
68
  workerServerHeartbeatAction: typeof workerServerHeartbeatAction;
69
+ createWorkspaceHydrationLeaseHooks: typeof createWorkspaceHydrationLeaseHooks;
45
70
  };
46
71
  type WorkspaceSyncTrigger = {
47
72
  type: "connect" | "inbound_head" | "write" | "edit" | "shell_inline" | "process_terminal" | "process_cancel" | "periodic" | "manual" | "remediation" | "remediation_confirm" | "remediation_reset";
@@ -103,6 +128,144 @@ export type WorkerSessionTarget = {
103
128
  ownerUserId: string;
104
129
  rootProfile: "visible_projects" | "canonical_sync";
105
130
  };
131
+ type WorkerClientMessage = {
132
+ type: "hello";
133
+ hostInfo?: Record<string, unknown>;
134
+ startupReady?: boolean;
135
+ } | {
136
+ type: "worker_progress";
137
+ progress: WorkerLifecycleProgress;
138
+ } | {
139
+ type: "heartbeat_lease";
140
+ operationId: string;
141
+ active: boolean;
142
+ /** A hydration transaction may block the worker for hours; the server bounds it separately from preparation leases. */
143
+ kind?: "hydration";
144
+ } | {
145
+ type: "startup_ready";
146
+ } | {
147
+ type: "active_processes";
148
+ processes: Array<{
149
+ runId: string;
150
+ target: WorkerSessionTarget;
151
+ sessionId: string;
152
+ platform: string;
153
+ arch: string;
154
+ mode: "foreground" | "detached";
155
+ pid?: number;
156
+ processGroupId?: number;
157
+ credentialId?: string;
158
+ argv: string[];
159
+ command: string;
160
+ cwd?: string;
161
+ startedAt: string;
162
+ interactive?: boolean;
163
+ workspaceEffect?: "none";
164
+ }>;
165
+ } | {
166
+ type: "pty_opened";
167
+ requestId: string;
168
+ ptyId: string;
169
+ } | {
170
+ type: "pty_output";
171
+ ptyId: string;
172
+ data: string;
173
+ } | {
174
+ type: "pty_exit";
175
+ ptyId: string;
176
+ exitCode: number;
177
+ signal?: number | string;
178
+ } | {
179
+ type: "pty_error";
180
+ ptyId: string;
181
+ requestId?: string;
182
+ error: string;
183
+ } | {
184
+ type: "operation_result";
185
+ requestId: string;
186
+ result?: WorkerOperationResult;
187
+ error?: string;
188
+ } | {
189
+ type: "exec_result";
190
+ requestId: string;
191
+ stdout: string;
192
+ stderr: string;
193
+ exitCode: number;
194
+ durationMs?: number;
195
+ error?: string;
196
+ } | {
197
+ type: "exec_accepted";
198
+ requestId: string;
199
+ runId: string;
200
+ } | {
201
+ type: "exec_started";
202
+ requestId: string;
203
+ runId: string;
204
+ cwd: string;
205
+ pid?: number;
206
+ processGroupId?: number;
207
+ } | {
208
+ type: "exec_start_error";
209
+ requestId: string;
210
+ runId: string;
211
+ error: string;
212
+ } | {
213
+ type: "exec_output";
214
+ runId: string;
215
+ stream: "stdout" | "stderr";
216
+ data: string;
217
+ } | {
218
+ type: "exec_exit";
219
+ runId: string;
220
+ exitCode: number;
221
+ durationMs?: number;
222
+ timedOut?: boolean;
223
+ workspaceEffect?: "none";
224
+ canonicalWorkspaceHead?: string;
225
+ } | {
226
+ type: "exec_error";
227
+ runId: string;
228
+ error: string;
229
+ durationMs?: number;
230
+ workspaceEffect?: "none";
231
+ canonicalWorkspaceHead?: string;
232
+ } | {
233
+ type: "cancel_result";
234
+ requestId: string;
235
+ runId: string;
236
+ cancelled: boolean;
237
+ message?: string;
238
+ } | {
239
+ type: "pong";
240
+ } | {
241
+ type: "workspace_configured";
242
+ requestId: string;
243
+ result: WorkspaceSyncResult;
244
+ pendingCheckouts: Array<{
245
+ projectId: string;
246
+ branchName: string;
247
+ }>;
248
+ aheadOfOriginBranches: WorkerAheadOfOriginBranch[];
249
+ deferredWorkspaceSyncForIncidentId?: string;
250
+ } | {
251
+ type: "workspace_sync_started";
252
+ attemptId: string;
253
+ } | {
254
+ type: "workspace_sync_result";
255
+ requestId?: string;
256
+ result: WorkspaceSyncResult;
257
+ } | {
258
+ type: "session_artifacts_sync_result";
259
+ requestId: string;
260
+ sessionId: string;
261
+ error?: string;
262
+ } | {
263
+ type: "update_clis_result";
264
+ requestId: string;
265
+ installedWorkerVersion?: string;
266
+ installedR5dctlVersion?: string;
267
+ error?: string;
268
+ };
106
269
  type WorkerServerMessage = {
107
270
  type: "connected";
108
271
  label: string;
@@ -371,6 +534,23 @@ type WorkerViewFileBytesResult = {
371
534
  width: number;
372
535
  height: number;
373
536
  };
537
+ type WorkerExecStdinAck = {
538
+ type: "exec_stdin";
539
+ runId: string;
540
+ bytesWritten: number;
541
+ eof: boolean;
542
+ };
543
+ type WorkerCreateProjectBranchResult = {
544
+ type: "create_project_branch";
545
+ branchId: string;
546
+ branchName: string;
547
+ baseCommitHash: string;
548
+ };
549
+ type WorkerDeleteProjectBranchResult = {
550
+ type: "delete_project_branch";
551
+ branchId: string;
552
+ branchName: string;
553
+ };
374
554
  type WorkerCodeEntry = {
375
555
  name: string;
376
556
  type: "file" | "directory";
@@ -389,6 +569,7 @@ type WorkerCodeReadResult = {
389
569
  mtime: string;
390
570
  base64: string;
391
571
  };
572
+ type WorkerOperationResult = WorkerReadFileResult | WorkerWriteFileResult | WorkerEditFileResult | WorkerGrepResult | WorkerFindResult | WorkerLsResult | WorkerViewFileBytesResult | WorkerExecStdinAck | WorkerCreateProjectBranchResult | WorkerDeleteProjectBranchResult | WorkerCodeListResult | WorkerCodeReadResult;
392
573
  declare class WorkspaceSyncAdmissionChangedError extends Error {
393
574
  readonly name = "WorkspaceSyncAdmissionChangedError";
394
575
  }
@@ -49,6 +49,26 @@ export type WorkspaceGitMount = {
49
49
  * unready state for concurrent visible work.
50
50
  */
51
51
  export declare function workspaceGitMountsForRemediation(mounts: readonly WorkspaceGitMount[]): WorkspaceGitMount[];
52
+ export type WorkspaceHydrationTransactionContext = {
53
+ /** Unique per transaction; pairs the begin and end observations. */
54
+ transactionId: string;
55
+ workspacePath: string;
56
+ /** Mounts the transaction may hydrate or carry across the durability barrier. */
57
+ mountIds: string[];
58
+ };
59
+ /**
60
+ * Observes the synchronous body of a hydration transaction (snapshot,
61
+ * hydration pass, durability barrier, receipt). That body deliberately never
62
+ * yields, so the worker cannot answer control-socket messages while it runs,
63
+ * and on a slow disk it can run for hours. `beforeTransaction` is awaited
64
+ * after the last asynchronous precheck and before the final busy observation,
65
+ * so it may announce the operation and yield once; `afterTransaction` runs
66
+ * after the body committed or rolled back.
67
+ */
68
+ export type WorkspaceHydrationTransactionHooks = {
69
+ beforeTransaction: (context: WorkspaceHydrationTransactionContext) => Promise<void> | void;
70
+ afterTransaction: (context: WorkspaceHydrationTransactionContext) => void;
71
+ };
52
72
  export type WorkspaceGitSyncOutcome = "no_change" | "updated" | "pushed" | "large_diff_blocked" | "conflict_blocked";
53
73
  export type WorkspaceGitSyncResult = {
54
74
  outcome: WorkspaceGitSyncOutcome;
@@ -137,6 +157,7 @@ export declare function ensureWorkspaceGitClone(input: {
137
157
  declare function mirrorMountsToWorkspace(workspacePath: string, mounts: readonly WorkspaceGitMount[]): void;
138
158
  export declare function hydrateWorkspaceGitMounts(workspacePath: string, mounts: readonly WorkspaceGitMount[], options?: {
139
159
  ignoreBusy?: boolean;
160
+ hydrationHooks?: WorkspaceHydrationTransactionHooks;
140
161
  }): Promise<{
141
162
  hydratedMountIds: string[];
142
163
  skippedMountIds: string[];
@@ -146,6 +167,7 @@ export declare function recoverWorkspaceGitHydration(workspacePath: string, moun
146
167
  deferMountIds?: ReadonlySet<string>;
147
168
  preserveResolutionInProgress?: boolean;
148
169
  preserveStaleBases?: boolean;
170
+ hydrationHooks?: WorkspaceHydrationTransactionHooks;
149
171
  }): Promise<void>;
150
172
  export declare function resetWorkspaceGit(input: {
151
173
  workspacePath: string;
@@ -157,6 +179,7 @@ export declare function resetWorkspaceGit(input: {
157
179
  email: string;
158
180
  };
159
181
  mounts: readonly WorkspaceGitMount[];
182
+ hydrationHooks?: WorkspaceHydrationTransactionHooks;
160
183
  }): Promise<{
161
184
  startingHead: string | null;
162
185
  localHead: string | null;
@@ -194,6 +217,8 @@ export declare function synchronizeWorkspaceGit(input: {
194
217
  publishedHead: string;
195
218
  activeMountIds: string[];
196
219
  }) => void | Promise<void>;
220
+ /** Announces each synchronous hydration transaction of this cycle; see WorkspaceHydrationTransactionHooks. */
221
+ hydrationHooks?: WorkspaceHydrationTransactionHooks;
197
222
  }): Promise<WorkspaceGitSyncResult>;
198
223
  export declare const workspaceGitSyncTestHarness: {
199
224
  commandArgs: typeof gitCommandArgs;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.99",
3
+ "version": "0.0.100",
4
4
  "type": "module",
5
5
  "main": "./dist/cjs/main.cjs",
6
6
  "module": "./dist/mjs/main.mjs",