@ricsam/r5d-worker 0.0.108 → 0.0.110

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
@@ -60,6 +60,9 @@ var import_plan_paths = require("./plan-paths.cjs");
60
60
  var import_plan_parser = require("./plan-parser.cjs");
61
61
  var import_plan_lint = require("./plan-lint.cjs");
62
62
  var import_node_fs = __toESM(require("node:fs"), 1);
63
+ var import_recovery_store = require("./recovery-store.cjs");
64
+ var import_recovery_output_window = require("./recovery-output-window.cjs");
65
+ var import_recovery_protocol = require("./recovery-protocol.cjs");
63
66
  var import_node_path = __toESM(require("node:path"), 1);
64
67
  var import_node_crypto = require("node:crypto");
65
68
  var import_node_os = __toESM(require("node:os"), 1);
@@ -161,6 +164,63 @@ const cancelledProcessRuns = /* @__PURE__ */ new Set();
161
164
  const activePtys = /* @__PURE__ */ new Map();
162
165
  const workspaceSyncPriorityPtyTargets = /* @__PURE__ */ new Map();
163
166
  let currentWorkerSocket = null;
167
+ const workerRuntimeId = crypto.randomUUID();
168
+ const workerOutputWindow = new import_recovery_output_window.WorkerOutputWindow();
169
+ let recoveryStore;
170
+ let workerReconnectAttempt = 0;
171
+ let workerRecoveryId;
172
+ let workerExecutionLeaseDeadline = 0;
173
+ let workerExecutionLeaseTimer;
174
+ let workerLeaseExpired = false;
175
+ const infrastructureStoppedRunIds = /* @__PURE__ */ new Set();
176
+ function isWorkerCommunicationFailure(message) {
177
+ return /Could not resolve host|Couldn't connect|Failed to connect|Connection reset|Connection timed out|remote end hung up|Empty reply|requested URL returned error: (?:408|429|5\d\d)|fetch failed|socket connection|ECONNRESET|ECONNREFUSED|ETIMEDOUT|Unable to connect|network connection was lost|Worker communication recovery expired|operation was aborted due to timeout|operation timed out|Failed to (?:fetch|download).*\((?:408|429|5\d\d)\)/i.test(
178
+ message
179
+ );
180
+ }
181
+ async function retryWorkerServerRead(read) {
182
+ let deadline;
183
+ let retry = 0;
184
+ while (true) {
185
+ try {
186
+ return await read();
187
+ } catch (error) {
188
+ if (!recoveryStore || !isWorkerCommunicationFailure(error instanceof Error ? error.message : String(error))) throw error;
189
+ deadline ??= Date.now() + import_recovery_protocol.WORKER_RECOVERY_TIMEOUT_MS;
190
+ if (workerLeaseExpired || Date.now() >= deadline) throw new WorkerServerUnavailableError("Worker communication recovery expired");
191
+ currentWorkerSocket?.close(1012, "Communication recovery");
192
+ await Bun.sleep((0, import_supervisor.workerReconnectDelayMs)(Math.random, retry++));
193
+ }
194
+ }
195
+ }
196
+ function renewWorkerExecutionLease(leaseMs) {
197
+ if (!Number.isFinite(leaseMs) || leaseMs <= 0) return;
198
+ workerLeaseExpired = false;
199
+ workerExecutionLeaseDeadline = Date.now() + Math.min(leaseMs, import_recovery_protocol.WORKER_RECOVERY_TIMEOUT_MS);
200
+ if (workerExecutionLeaseTimer) clearTimeout(workerExecutionLeaseTimer);
201
+ workerExecutionLeaseTimer = setTimeout(
202
+ () => {
203
+ if (Date.now() < workerExecutionLeaseDeadline) return;
204
+ workerLeaseExpired = true;
205
+ for (const operation of recoveryStore?.operations() ?? []) {
206
+ if (operation.sessionId && operation.state !== "completed") recoveryStore?.cancelSession(operation.sessionId);
207
+ }
208
+ for (const [runId, active] of activeProcesses) {
209
+ if (!active.sessionId) continue;
210
+ infrastructureStoppedRunIds.add(runId);
211
+ recoveryStore?.unknownRun(runId);
212
+ cancelledProcessRuns.add(runId);
213
+ closeProcessStdin(active);
214
+ void (0, import_process_tree.terminateProcessTree)(active.process).catch(
215
+ (error) => process.stderr.write(`[r5d-worker] execution lease termination failed: ${error}
216
+ `)
217
+ );
218
+ }
219
+ },
220
+ Math.min(leaseMs, import_recovery_protocol.WORKER_RECOVERY_TIMEOUT_MS)
221
+ );
222
+ workerExecutionLeaseTimer.unref();
223
+ }
164
224
  let workerAdmissionGeneration = 0;
165
225
  const workspaceMutationGate = new import_workspace_mutation_gate.WorkspaceMutationGate();
166
226
  let workspaceSyncQueue = Promise.resolve();
@@ -619,40 +679,46 @@ function parseRemoteFileManifest(value, collectionKey, label) {
619
679
  });
620
680
  }
621
681
  async function fetchRemoteFileManifest(input) {
622
- const response = await fetch(input.url, {
623
- headers: {
624
- Authorization: `Bearer ${input.token}`
682
+ return retryWorkerServerRead(async () => {
683
+ const response = await fetch(input.url, {
684
+ signal: AbortSignal.timeout(3e4),
685
+ headers: {
686
+ Authorization: `Bearer ${input.token}`
687
+ }
688
+ });
689
+ if (!response.ok) {
690
+ const detail = (await readResponseText(response)).trim();
691
+ throw new Error(`Failed to fetch ${input.label} manifest (${response.status})${detail ? `: ${detail}` : ""}`);
625
692
  }
693
+ return parseRemoteFileManifest(await response.json(), input.collectionKey, input.label);
626
694
  });
627
- if (!response.ok) {
628
- const detail = (await readResponseText(response)).trim();
629
- throw new Error(`Failed to fetch ${input.label} manifest (${response.status})${detail ? `: ${detail}` : ""}`);
630
- }
631
- return parseRemoteFileManifest(await response.json(), input.collectionKey, input.label);
632
695
  }
633
696
  async function downloadRemoteSyncedFile(input) {
634
- const targetPath = import_node_path.default.join(input.targetDir, input.file.filename);
635
- assertInsideRoot(input.targetDir, targetPath, input.label);
636
- const response = await fetch(input.url, {
637
- headers: {
638
- Authorization: `Bearer ${input.token}`
697
+ return retryWorkerServerRead(async () => {
698
+ const targetPath = import_node_path.default.join(input.targetDir, input.file.filename);
699
+ assertInsideRoot(input.targetDir, targetPath, input.label);
700
+ const response = await fetch(input.url, {
701
+ signal: AbortSignal.timeout(3e4),
702
+ headers: {
703
+ Authorization: `Bearer ${input.token}`
704
+ }
705
+ });
706
+ if (!response.ok) {
707
+ const detail = (await readResponseText(response)).trim();
708
+ throw new Error(`Failed to download ${input.label} ${input.file.filename} (${response.status})${detail ? `: ${detail}` : ""}`);
709
+ }
710
+ const buffer = Buffer.from(await response.arrayBuffer());
711
+ const receivedHash = (0, import_node_crypto.createHash)("sha256").update(buffer).digest("hex");
712
+ if (receivedHash !== input.file.sha256) {
713
+ throw new Error(`Downloaded ${input.label} ${input.file.filename} failed sha256 verification`);
639
714
  }
715
+ if (buffer.length !== input.file.size) {
716
+ throw new Error(`Downloaded ${input.label} ${input.file.filename} has unexpected size`);
717
+ }
718
+ const tempPath = `${targetPath}.${process.pid}.${Date.now()}.tmp`;
719
+ import_node_fs.default.writeFileSync(tempPath, buffer);
720
+ import_node_fs.default.renameSync(tempPath, targetPath);
640
721
  });
641
- if (!response.ok) {
642
- const detail = (await readResponseText(response)).trim();
643
- throw new Error(`Failed to download ${input.label} ${input.file.filename} (${response.status})${detail ? `: ${detail}` : ""}`);
644
- }
645
- const buffer = Buffer.from(await response.arrayBuffer());
646
- const receivedHash = (0, import_node_crypto.createHash)("sha256").update(buffer).digest("hex");
647
- if (receivedHash !== input.file.sha256) {
648
- throw new Error(`Downloaded ${input.label} ${input.file.filename} failed sha256 verification`);
649
- }
650
- if (buffer.length !== input.file.size) {
651
- throw new Error(`Downloaded ${input.label} ${input.file.filename} has unexpected size`);
652
- }
653
- const tempPath = `${targetPath}.${process.pid}.${Date.now()}.tmp`;
654
- import_node_fs.default.writeFileSync(tempPath, buffer);
655
- import_node_fs.default.renameSync(tempPath, targetPath);
656
722
  }
657
723
  async function syncRemoteFileSet(input) {
658
724
  import_node_fs.default.mkdirSync(input.targetDir, { recursive: true });
@@ -779,6 +845,7 @@ async function verifyR5dctlAuth(baseUrl, token, workerLabel) {
779
845
  let response;
780
846
  try {
781
847
  response = await fetch(statusUrl, {
848
+ signal: AbortSignal.timeout(3e4),
782
849
  headers: {
783
850
  Authorization: `Bearer ${token}`
784
851
  }
@@ -2942,23 +3009,46 @@ function closeProcessStdin(active) {
2942
3009
  }
2943
3010
  active.stdin = void 0;
2944
3011
  }
2945
- function sendWorkerMessage(ws, message) {
3012
+ function sendReplayWorkerMessage(ws, message) {
2946
3013
  const target = currentWorkerSocket ?? ws;
2947
- try {
2948
- target.send(JSON.stringify(message));
2949
- } catch (error) {
2950
- process.stderr.write(`[r5d-worker] failed to send ${message.type}: ${error instanceof Error ? error.message : String(error)}
2951
- `);
3014
+ if (target.readyState !== WebSocket.OPEN) return false;
3015
+ const send = (payload) => {
3016
+ try {
3017
+ target.send(JSON.stringify(payload));
3018
+ return true;
3019
+ } catch {
3020
+ return false;
3021
+ }
3022
+ };
3023
+ return message.type === "exec_output" ? workerOutputWindow.send(message, send, target.bufferedAmount) : send(message);
3024
+ }
3025
+ function sendWorkerMessage(ws, message) {
3026
+ if (recoveryStore && "requestId" in message && typeof message.requestId === "string" && recoveryStore.isUnknown(message.requestId))
3027
+ return;
3028
+ const transportError = "error" in message && typeof message.error === "string" ? message.error : message.type === "workspace_sync_result" && message.result.outcome === "failed" ? message.result.error : void 0;
3029
+ if (recoveryStore && transportError && isWorkerCommunicationFailure(transportError) && "requestId" in message && typeof message.requestId === "string") {
3030
+ recoveryStore.unknown(message.requestId);
3031
+ currentWorkerSocket?.close(1012, "Communication recovery");
3032
+ return;
2952
3033
  }
3034
+ const recorded = recoveryStore?.record(message) ?? message;
3035
+ if ((message.type === "exec_exit" || message.type === "exec_error") && infrastructureStoppedRunIds.has(message.runId)) {
3036
+ recoveryStore?.unknownRun(message.runId);
3037
+ return;
3038
+ }
3039
+ if (message.type === "exec_output" && workerRecoveryId !== void 0) return;
3040
+ sendReplayWorkerMessage(ws, recorded);
3041
+ }
3042
+ function sendSerializedWorkerMessage(ws, serialized) {
3043
+ sendWorkerMessage(ws, JSON.parse(serialized));
2953
3044
  }
2954
3045
  function sendWorkerMessageFromCurrentSource(ws, message) {
2955
- if (currentWorkerSocket !== ws || ws.readyState !== WebSocket.OPEN) return;
2956
- try {
2957
- ws.send(JSON.stringify(message));
2958
- } catch (error) {
2959
- process.stderr.write(`[r5d-worker] failed to send ${message.type}: ${error instanceof Error ? error.message : String(error)}
2960
- `);
3046
+ if ("requestId" in message && typeof message.requestId === "string" && message.type !== "workspace_configured") {
3047
+ sendWorkerMessage(ws, message);
3048
+ return;
2961
3049
  }
3050
+ if (currentWorkerSocket !== ws || ws.readyState !== WebSocket.OPEN) return;
3051
+ sendWorkerMessage(ws, message);
2962
3052
  }
2963
3053
  function buildActiveProcessReports() {
2964
3054
  return Array.from(activeProcesses.entries()).filter(([, active]) => active.sessionId.length > 0).map(([runId, active]) => ({
@@ -3478,13 +3568,15 @@ async function startWorker(options, projectRuntime = {
3478
3568
  reconciledProjectConfigFingerprints: /* @__PURE__ */ new Map(),
3479
3569
  gitIdentity: null
3480
3570
  }) {
3481
- advanceWorkerAdmissionGeneration();
3482
3571
  const config = readConfig(options.configPath);
3483
3572
  const { baseUrl, token } = resolveCredentials(options, config);
3484
3573
  initializeCredentialStoreDirectoryOnce();
3485
3574
  const label = requireValue(options.label, "Missing required --label <label>");
3486
3575
  validateLabel(label);
3487
3576
  const rootDir = import_node_path.default.resolve(options.rootDir ?? process.env.R5D_ROOT ?? defaultRootDir());
3577
+ recoveryStore ??= new import_recovery_store.WorkerRecoveryStore(
3578
+ import_node_path.default.join(rootDir, ".worker-recovery", (0, import_node_crypto.createHash)("sha256").update(`${baseUrl}\0${label}`).digest("hex"), "protocol.sqlite")
3579
+ );
3488
3580
  const projectsRoot = import_node_path.default.join(rootDir, "projects");
3489
3581
  const syncRoot = import_node_path.default.join(rootDir, "sync");
3490
3582
  const workspaceShadowRoot = import_node_path.default.join(syncRoot, "workspace");
@@ -3505,7 +3597,7 @@ async function startWorker(options, projectRuntime = {
3505
3597
  `);
3506
3598
  runGit(["--version"]);
3507
3599
  await verifyR5dctlAuth(baseUrl, token, label);
3508
- const initializedWorkspaceState = await workspaceSyncSingleFlight.runExclusive(() => {
3600
+ const initializedWorkspaceState = projectRuntime.initializedWorkspaceState ?? await workspaceSyncSingleFlight.runExclusive(() => {
3509
3601
  import_node_fs.default.mkdirSync(projectsRoot, { recursive: true });
3510
3602
  import_node_fs.default.mkdirSync(syncRoot, { recursive: true });
3511
3603
  import_node_fs.default.mkdirSync(artifactRoot, { recursive: true });
@@ -3516,8 +3608,9 @@ async function startWorker(options, projectRuntime = {
3516
3608
  });
3517
3609
  return { store, state: store.read() };
3518
3610
  });
3611
+ projectRuntime.initializedWorkspaceState = initializedWorkspaceState;
3519
3612
  const projectWorkspaceStateStore = initializedWorkspaceState.store;
3520
- let projectWorkspaceState = initializedWorkspaceState.state;
3613
+ let projectWorkspaceState = initializedWorkspaceState.store.read();
3521
3614
  const { projectConfigById, readyProjectIds, reconciledProjectConfigFingerprints } = projectRuntime;
3522
3615
  const pendingCheckouts = /* @__PURE__ */ new Map();
3523
3616
  const lastObservedProjectHeads = /* @__PURE__ */ new Map();
@@ -3557,11 +3650,12 @@ async function startWorker(options, projectRuntime = {
3557
3650
  let workspaceConfigurationReceiptGeneration = 0;
3558
3651
  let workspaceAutomaticTimer;
3559
3652
  let workspacePeriodicTimer;
3560
- let pendingAutomaticTrigger;
3653
+ let pendingAutomaticTrigger = projectRuntime.pendingAutomaticTrigger;
3561
3654
  const pendingCreatedBranchPublicationNotBefore = /* @__PURE__ */ new Map();
3562
3655
  const workspaceProjectionLedger = new import_workspace_projection_ledger.WorkspaceProjectionLedger();
3563
3656
  let automaticSyncInFlight = false;
3564
3657
  let terminalReplayTimer;
3658
+ let outputReplayTimer;
3565
3659
  let workspaceSyncRequestsInFlight = 0;
3566
3660
  let lastWorkspaceSyncMirrorObservationMs = 0;
3567
3661
  let lastInventoriedMissingCheckouts;
@@ -4653,7 +4747,7 @@ async function startWorker(options, projectRuntime = {
4653
4747
  });
4654
4748
  const workspaceSyncAdmissionSnapshotsMatch = (left, right) => left.generation === right.generation && left.activeIncidentId === right.activeIncidentId && left.deferredIncidentId === right.deferredIncidentId;
4655
4749
  const assertWorkspaceSyncStillAdmitted = (admitted, trigger) => {
4656
- if (!workspaceSyncAdmissionSnapshotsMatch(admitted, captureWorkspaceSyncAdmission()) || currentWorkerSocket !== ws || ws.readyState !== WebSocket.OPEN || !deferredWorkspaceSyncTriggerIsAllowed(deferredWorkspaceConfiguration, activeWorkspaceIncidentId, trigger)) {
4750
+ if (!workspaceSyncAdmissionSnapshotsMatch(admitted, captureWorkspaceSyncAdmission()) || !deferredWorkspaceSyncTriggerIsAllowed(deferredWorkspaceConfiguration, activeWorkspaceIncidentId, trigger)) {
4657
4751
  throw new WorkspaceSyncAdmissionChangedError("Workspace synchronization admission changed while asynchronous work was in flight");
4658
4752
  }
4659
4753
  };
@@ -4707,6 +4801,7 @@ async function startWorker(options, projectRuntime = {
4707
4801
  bouncePending: unsafeWorkspaceSyncBouncePending,
4708
4802
  invalidateExecution: () => {
4709
4803
  workspaceConfigured = false;
4804
+ projectRuntime.configuration = void 0;
4710
4805
  advanceWorkerAdmissionGeneration();
4711
4806
  },
4712
4807
  scheduleRecoveryBounce: () => {
@@ -4817,7 +4912,7 @@ async function startWorker(options, projectRuntime = {
4817
4912
  if (currentWorkerSocket !== ws || ws.readyState !== WebSocket.OPEN) {
4818
4913
  throw new Error("Cannot start autonomous workspace synchronization without an open worker control socket");
4819
4914
  }
4820
- ws.send(JSON.stringify({ type: "workspace_sync_started", attemptId }));
4915
+ sendSerializedWorkerMessage(ws, JSON.stringify({ type: "workspace_sync_started", attemptId }));
4821
4916
  }
4822
4917
  let queueMs = 0;
4823
4918
  let prepareMs = 0;
@@ -4869,6 +4964,7 @@ async function startWorker(options, projectRuntime = {
4869
4964
  };
4870
4965
  const scheduleAutomaticWorkspaceSync = (trigger, delayMs = WORKSPACE_GIT_QUIET_MS) => {
4871
4966
  pendingAutomaticTrigger = trigger;
4967
+ projectRuntime.pendingAutomaticTrigger = trigger;
4872
4968
  const pendingBranchDeferral = () => (0, import_workspace_automatic_sync_policy.pendingCreatedBranchAutomaticSyncDeferral)(
4873
4969
  projectWorkspaceState.locallyPendingCreatedBranches,
4874
4970
  activeWorkspaceMutationTargets(),
@@ -4891,6 +4987,7 @@ async function startWorker(options, projectRuntime = {
4891
4987
  return;
4892
4988
  }
4893
4989
  pendingAutomaticTrigger = void 0;
4990
+ projectRuntime.pendingAutomaticTrigger = void 0;
4894
4991
  automaticSyncInFlight = true;
4895
4992
  void runWorkspaceSync({ trigger: scheduledTrigger }).then((result) => {
4896
4993
  if (result.outcome === "failed") {
@@ -4914,9 +5011,14 @@ async function startWorker(options, projectRuntime = {
4914
5011
  workspaceAutomaticTimer.unref();
4915
5012
  };
4916
5013
  const markWorkspaceDirty = (trigger, force = false, target) => {
5014
+ if (projectRuntime.markWorkspaceDirty && projectRuntime.markWorkspaceDirty !== markWorkspaceDirty) {
5015
+ projectRuntime.markWorkspaceDirty(trigger, force, target);
5016
+ return;
5017
+ }
4917
5018
  if (target) recordVisibleWorkspaceMutation(target);
4918
5019
  scheduleAutomaticWorkspaceSync(trigger, force ? 0 : WORKSPACE_GIT_QUIET_MS);
4919
5020
  };
5021
+ projectRuntime.markWorkspaceDirty = markWorkspaceDirty;
4920
5022
  const targetMayMutateVisibleWorkspace = (target) => target.type === "project" || target.rootProfile === "visible_projects";
4921
5023
  const resolveMessageTarget = (target) => {
4922
5024
  if (!workspaceConfigured) throw new Error("Worker workspace configuration has not completed successfully");
@@ -5434,6 +5536,10 @@ async function startWorker(options, projectRuntime = {
5434
5536
  Authorization: `Bearer ${token}`
5435
5537
  }
5436
5538
  });
5539
+ const connectionAttemptTimer = setTimeout(() => {
5540
+ if (ws.readyState === WebSocket.CONNECTING) ws.terminate();
5541
+ }, 3e4);
5542
+ connectionAttemptTimer.unref();
5437
5543
  let lastServerControlMessageAtMs = performance.now();
5438
5544
  let serverHeartbeatProbeStartedAtMs;
5439
5545
  let lastServerHeartbeatSweepAtMs = performance.now();
@@ -5529,7 +5635,9 @@ async function startWorker(options, projectRuntime = {
5529
5635
  process.on("SIGTERM", handleGracefulShutdown);
5530
5636
  process.on("SIGINT", handleGracefulShutdown);
5531
5637
  ws.addEventListener("open", () => {
5638
+ clearTimeout(connectionAttemptTimer);
5532
5639
  currentWorkerSocket = ws;
5640
+ workerRecoveryId = "connecting";
5533
5641
  noteServerControlActivity();
5534
5642
  lastServerHeartbeatSweepAtMs = performance.now();
5535
5643
  serverHeartbeatWatchdog = setInterval(() => {
@@ -5573,6 +5681,9 @@ async function startWorker(options, projectRuntime = {
5573
5681
  });
5574
5682
  const hello = {
5575
5683
  type: "hello",
5684
+ resumableProtocol: import_recovery_protocol.WORKER_RESUMABLE_PROTOCOL,
5685
+ runtimeId: workerRuntimeId,
5686
+ ledgerId: recoveryStore.ledgerId,
5576
5687
  startupReady: startupRecoveryComplete,
5577
5688
  hostInfo: {
5578
5689
  hostname: (0, import_node_os.hostname)(),
@@ -5597,15 +5708,17 @@ async function startWorker(options, projectRuntime = {
5597
5708
  root: rootDir
5598
5709
  }
5599
5710
  };
5600
- ws.send(JSON.stringify(hello));
5601
- sendActiveProcessReport(ws);
5602
- for (const terminal of pendingProcessTerminals.values()) {
5603
- sendWorkerMessage(ws, terminal);
5604
- }
5605
- terminalReplayTimer = setInterval(() => {
5606
- for (const terminal of pendingProcessTerminals.values()) {
5607
- sendWorkerMessage(ws, terminal);
5711
+ sendSerializedWorkerMessage(ws, JSON.stringify(hello));
5712
+ outputReplayTimer = setInterval(() => {
5713
+ if (workerRecoveryId !== void 0 || currentWorkerSocket !== ws) return;
5714
+ for (const output of recoveryStore.output()) {
5715
+ if (!sendReplayWorkerMessage(ws, output)) break;
5608
5716
  }
5717
+ }, 250);
5718
+ outputReplayTimer.unref();
5719
+ terminalReplayTimer = setInterval(() => {
5720
+ if (workerRecoveryId !== void 0) return;
5721
+ for (const terminal of recoveryStore?.terminals() ?? []) sendReplayWorkerMessage(ws, terminal);
5609
5722
  }, 5e3);
5610
5723
  terminalReplayTimer.unref();
5611
5724
  process.stdout.write("[r5d-worker] connected\n");
@@ -5613,18 +5726,103 @@ async function startWorker(options, projectRuntime = {
5613
5726
  ws.addEventListener("message", (event) => {
5614
5727
  noteServerControlActivity();
5615
5728
  void (async () => {
5729
+ if (currentWorkerSocket !== ws) return;
5616
5730
  const message = JSON.parse(String(event.data));
5731
+ if (message.type === "recovery_request") {
5732
+ workerRecoveryId = message.recoveryId;
5733
+ sendActiveProcessReport(ws);
5734
+ sendWorkerMessage(ws, {
5735
+ type: "recovery_state",
5736
+ recoveryId: message.recoveryId,
5737
+ runtimeId: workerRuntimeId,
5738
+ ledgerId: recoveryStore.ledgerId,
5739
+ operations: recoveryStore.operations()
5740
+ });
5741
+ const cursors = new Map(recoveryStore.outputCursors().map((cursor) => [cursor.runId, cursor]));
5742
+ for (const cursor of message.outputOffsets) cursors.set(cursor.runId, cursor);
5743
+ workerOutputWindow.reset([...cursors.values()]);
5744
+ for (const response of recoveryStore.responses(message.requests)) sendReplayWorkerMessage(ws, response);
5745
+ for (const output of recoveryStore.output(message.outputOffsets)) {
5746
+ while (true) {
5747
+ if (currentWorkerSocket !== ws || workerRecoveryId !== message.recoveryId || ws.readyState !== WebSocket.OPEN || workerLeaseExpired)
5748
+ return;
5749
+ if (sendReplayWorkerMessage(ws, output)) break;
5750
+ await Bun.sleep(10);
5751
+ }
5752
+ }
5753
+ if (currentWorkerSocket !== ws || workerRecoveryId !== message.recoveryId) return;
5754
+ for (const terminal of recoveryStore.terminals()) sendReplayWorkerMessage(ws, terminal);
5755
+ sendWorkerMessage(ws, { type: "recovery_complete", recoveryId: message.recoveryId });
5756
+ return;
5757
+ }
5758
+ if (message.type === "recovery_ready") {
5759
+ if (workerRecoveryId !== message.recoveryId) return;
5760
+ workerRecoveryId = void 0;
5761
+ workerReconnectAttempt = 0;
5762
+ renewWorkerExecutionLease(message.leaseMs);
5763
+ return;
5764
+ }
5765
+ if (message.type === "worker_execution_lease") {
5766
+ renewWorkerExecutionLease(message.leaseMs);
5767
+ return;
5768
+ }
5769
+ if (message.type === "operation_result_ack") {
5770
+ recoveryStore.acknowledgeResult(message.requestId);
5771
+ return;
5772
+ }
5773
+ if (message.type === "exec_output_ack") {
5774
+ recoveryStore.acknowledgeOutput(message.runId, message.stream, message.nextOffset);
5775
+ workerOutputWindow.acknowledge(message.runId, message.stream, message.nextOffset);
5776
+ return;
5777
+ }
5778
+ if (message.type === "cancel_session") {
5779
+ recoveryStore.cancelSession(message.sessionId);
5780
+ await Promise.all(
5781
+ [...activeProcesses].filter(([, active]) => active.sessionId === message.sessionId).map(async ([runId, active]) => {
5782
+ cancelledProcessRuns.add(runId);
5783
+ closeProcessStdin(active);
5784
+ await (0, import_process_tree.terminateProcessTree)(active.process);
5785
+ })
5786
+ );
5787
+ sendWorkerMessage(ws, { type: "cancel_session_ack", cancellationId: message.cancellationId });
5788
+ return;
5789
+ }
5617
5790
  if (!startupRecoveryComplete && message.type !== "ping") await startupRecoveryPromise;
5618
5791
  if (!startupRecoveryComplete && message.type !== "ping") return;
5792
+ if (currentWorkerSocket !== ws) return;
5793
+ const operationRequestId = "requestId" in message && typeof message.requestId === "string" && message.type !== "workspace_config" ? message.requestId : void 0;
5794
+ if (operationRequestId) {
5795
+ const admission = recoveryStore.admit({ ...message, requestId: operationRequestId });
5796
+ if (admission === "conflict") {
5797
+ ws.close(1008, "Operation identity conflict");
5798
+ return;
5799
+ }
5800
+ sendWorkerMessage(ws, { type: "operation_received", requestId: operationRequestId });
5801
+ if (admission !== "new") {
5802
+ const response = recoveryStore.response(operationRequestId);
5803
+ if (response && admission !== "unknown") sendReplayWorkerMessage(ws, response);
5804
+ return;
5805
+ }
5806
+ }
5619
5807
  const messageAdmissionGeneration = workerAdmissionGeneration;
5620
- const assertMessageAdmission = () => assertWorkerChildAdmission({
5621
- capturedGeneration: messageAdmissionGeneration,
5622
- currentGeneration: workerAdmissionGeneration,
5623
- sourceSocketIsCurrent: currentWorkerSocket === ws,
5624
- sourceSocketOpen: ws.readyState === WebSocket.OPEN,
5625
- workspaceConfigured,
5626
- runtimeHealthy: !shutdownAfterClose
5627
- });
5808
+ const admissionCredentials = configuredCredentialGenerationFingerprint;
5809
+ const assertMessageAdmission = () => {
5810
+ if (operationRequestId) {
5811
+ if (workerLeaseExpired || recoveryStore.isRequestCancelled(operationRequestId) || admissionCredentials !== configuredCredentialGenerationFingerprint || shutdownAfterClose || !workspaceConfigured) {
5812
+ recoveryStore.unknown(operationRequestId);
5813
+ throw new StaleWorkerAdmissionError();
5814
+ }
5815
+ return;
5816
+ }
5817
+ assertWorkerChildAdmission({
5818
+ capturedGeneration: messageAdmissionGeneration,
5819
+ currentGeneration: workerAdmissionGeneration,
5820
+ sourceSocketIsCurrent: currentWorkerSocket === ws,
5821
+ sourceSocketOpen: ws.readyState === WebSocket.OPEN,
5822
+ workspaceConfigured,
5823
+ runtimeHealthy: !shutdownAfterClose
5824
+ });
5825
+ };
5628
5826
  if (message.type === "connected") {
5629
5827
  return;
5630
5828
  }
@@ -5640,11 +5838,15 @@ async function startWorker(options, projectRuntime = {
5640
5838
  if (message.type === "workspace_config") {
5641
5839
  const receiptGeneration = ++workspaceConfigurationReceiptGeneration;
5642
5840
  const refreshesDeferredConfiguration = deferredWorkspaceConfiguration !== null && activeWorkspaceIncidentId === null && message.deferWorkspaceSyncForIncidentId === void 0;
5643
- advanceWorkerAdmissionGeneration();
5644
- const configured = await configureWorkerWorkspace(message, receiptGeneration);
5645
- unsafeWorkspaceSyncBouncePending = false;
5646
- if (configured.result.outcome !== "failed") unsafeWorkspaceSyncRecoveryAttempts = 0;
5647
- sendWorkerMessageFromCurrentSource(ws, {
5841
+ const digest = (0, import_recovery_store.workerOperationDigest)(message);
5842
+ const cached = projectRuntime.configuration;
5843
+ const canReuseConfiguration = cached?.digest === digest && cached.workspaceSync?.remoteUrl === message.workspaceRemoteUrl && Boolean(cached.workspaceSync.credentialHelper) && cached.workspaceSync.credentialUsername === workerCredentialUsername && cached.workspaceSync.gitIdentity?.name === message.gitIdentity.name && cached.workspaceSync.gitIdentity?.email === message.gitIdentity.email && !unsafeWorkspaceSyncBouncePending && credentialGenerationFingerprint({ ...message, workerBaseUrl: baseUrl, workerAuthHeader: bearerAuthHeader }) === configuredCredentialGenerationFingerprint;
5844
+ if (!canReuseConfiguration) {
5845
+ advanceWorkerAdmissionGeneration();
5846
+ projectRuntime.configuration = void 0;
5847
+ }
5848
+ const configured = canReuseConfiguration ? cached.configured : await configureWorkerWorkspace(message, receiptGeneration);
5849
+ const sendConfigured = () => sendWorkerMessageFromCurrentSource(ws, {
5648
5850
  type: "workspace_configured",
5649
5851
  requestId: message.requestId,
5650
5852
  result: configured.result,
@@ -5652,6 +5854,34 @@ async function startWorker(options, projectRuntime = {
5652
5854
  aheadOfOriginBranches: configured.aheadOfOriginBranches,
5653
5855
  ...configured.deferredWorkspaceSyncForIncidentId ? { deferredWorkspaceSyncForIncidentId: configured.deferredWorkspaceSyncForIncidentId } : {}
5654
5856
  });
5857
+ if (receiptGeneration !== workspaceConfigurationReceiptGeneration || currentWorkerSocket !== ws || ws.readyState !== WebSocket.OPEN) {
5858
+ sendConfigured();
5859
+ return;
5860
+ }
5861
+ if (canReuseConfiguration && configured.result.outcome !== "failed") {
5862
+ workspaceRemoteUrl = cached.workspaceSync.remoteUrl;
5863
+ workspaceCredentialHelper = cached.workspaceSync.credentialHelper;
5864
+ workspaceCredentialUsername = cached.workspaceSync.credentialUsername;
5865
+ workspaceGitIdentity = cached.workspaceSync.gitIdentity;
5866
+ workspaceConfigured = true;
5867
+ activeWorkspaceIncidentId = cached.incidentId;
5868
+ deferredWorkspaceConfiguration = cached.deferred;
5869
+ }
5870
+ projectRuntime.configuration = configured.result.outcome !== "failed" && workspaceRemoteUrl && workspaceCredentialHelper && workspaceCredentialUsername && workspaceGitIdentity ? {
5871
+ digest,
5872
+ workspaceSync: {
5873
+ remoteUrl: workspaceRemoteUrl,
5874
+ credentialHelper: workspaceCredentialHelper,
5875
+ credentialUsername: workspaceCredentialUsername,
5876
+ gitIdentity: workspaceGitIdentity
5877
+ },
5878
+ configured,
5879
+ incidentId: activeWorkspaceIncidentId,
5880
+ deferred: deferredWorkspaceConfiguration
5881
+ } : void 0;
5882
+ unsafeWorkspaceSyncBouncePending = false;
5883
+ if (configured.result.outcome !== "failed") unsafeWorkspaceSyncRecoveryAttempts = 0;
5884
+ sendConfigured();
5655
5885
  sendLifecycleProgress(
5656
5886
  configured.result.outcome === "failed" ? {
5657
5887
  phase: "failed",
@@ -5687,16 +5917,31 @@ async function startWorker(options, projectRuntime = {
5687
5917
  return;
5688
5918
  }
5689
5919
  if (message.type === "sync_workspace") {
5690
- await runWorkspaceSync({
5691
- requestId: message.requestId,
5692
- attemptId: message.attemptId,
5693
- trigger: message.trigger,
5694
- confirmedLargeDiff: message.confirmedLargeDiff,
5695
- confirmationReason: message.confirmationReason,
5696
- resetToCanonical: message.resetToCanonical,
5697
- requiredAncestorHeads: message.requiredAncestorHeads
5698
- });
5699
- return;
5920
+ let retry = 0;
5921
+ let recoveryDeadline;
5922
+ while (true) {
5923
+ const result = await runWorkspaceSync({
5924
+ requestId: message.requestId,
5925
+ attemptId: message.attemptId,
5926
+ trigger: message.trigger,
5927
+ confirmedLargeDiff: message.confirmedLargeDiff,
5928
+ confirmationReason: message.confirmationReason,
5929
+ resetToCanonical: message.resetToCanonical,
5930
+ requiredAncestorHeads: message.requiredAncestorHeads,
5931
+ sendResult: false
5932
+ });
5933
+ if (result.outcome !== "failed" || !isWorkerCommunicationFailure(result.error ?? "")) {
5934
+ sendWorkspaceSyncResult(message.requestId, result);
5935
+ return;
5936
+ }
5937
+ recoveryDeadline ??= Date.now() + import_recovery_protocol.WORKER_RECOVERY_TIMEOUT_MS;
5938
+ if (workerLeaseExpired || Date.now() >= recoveryDeadline) {
5939
+ recoveryStore.unknown(message.requestId);
5940
+ return;
5941
+ }
5942
+ currentWorkerSocket?.close(1012, "Communication recovery");
5943
+ await Bun.sleep((0, import_supervisor.workerReconnectDelayMs)(Math.random, retry++));
5944
+ }
5700
5945
  }
5701
5946
  if (message.type === "create_project_branch") {
5702
5947
  if (workspaceOperationsAreFenced(deferredWorkspaceConfiguration, activeWorkspaceIncidentId)) {
@@ -5962,6 +6207,7 @@ async function startWorker(options, projectRuntime = {
5962
6207
  }
5963
6208
  if (message.type === "exec_terminal_ack") {
5964
6209
  pendingProcessTerminals.delete(message.runId);
6210
+ recoveryStore.acknowledgeTerminal(message.runId);
5965
6211
  return;
5966
6212
  }
5967
6213
  if (message.type === "port_forward_connect") {
@@ -6013,7 +6259,7 @@ async function startWorker(options, projectRuntime = {
6013
6259
  return;
6014
6260
  }
6015
6261
  if (message.type === "ping") {
6016
- ws.send(JSON.stringify({ type: "pong" }));
6262
+ sendSerializedWorkerMessage(ws, JSON.stringify({ type: "pong" }));
6017
6263
  return;
6018
6264
  }
6019
6265
  if (message.type === "cancel") {
@@ -6033,7 +6279,8 @@ async function startWorker(options, projectRuntime = {
6033
6279
  cancelledProcessRuns.add(message.runId);
6034
6280
  cancelMessage = "Cancellation queued before command start";
6035
6281
  }
6036
- ws.send(
6282
+ sendSerializedWorkerMessage(
6283
+ ws,
6037
6284
  JSON.stringify({
6038
6285
  type: "cancel_result",
6039
6286
  requestId: message.requestId,
@@ -6045,7 +6292,8 @@ async function startWorker(options, projectRuntime = {
6045
6292
  return;
6046
6293
  }
6047
6294
  if (message.type === "exec_stdin") {
6048
- const sendAck = (payload) => ws.send(
6295
+ const sendAck = (payload) => sendSerializedWorkerMessage(
6296
+ ws,
6049
6297
  JSON.stringify({
6050
6298
  type: "operation_result",
6051
6299
  requestId: message.requestId,
@@ -6168,16 +6416,19 @@ async function startWorker(options, projectRuntime = {
6168
6416
  ptyId: message.ptyId,
6169
6417
  error: `Shell input is deferred for workspace incident ${deferredWorkspaceConfiguration?.incidentId ?? activeWorkspaceIncidentId ?? "unknown"}`
6170
6418
  });
6419
+ sendWorkerMessage(ws, { type: "operation_completed", requestId: message.requestId });
6171
6420
  return;
6172
6421
  }
6173
6422
  if (activePty && targetMayMutateVisibleWorkspace(activePty.target)) {
6174
6423
  markWorkspaceDirty({ type: "shell_inline", detail: `pty ${message.ptyId}` }, false, activePty.target);
6175
6424
  }
6176
6425
  writePty(ws, message);
6426
+ sendWorkerMessage(ws, { type: "operation_completed", requestId: message.requestId });
6177
6427
  return;
6178
6428
  }
6179
6429
  if (message.type === "pty_resize") {
6180
6430
  resizePty(message);
6431
+ sendWorkerMessage(ws, { type: "operation_completed", requestId: message.requestId });
6181
6432
  return;
6182
6433
  }
6183
6434
  if (message.type === "pty_close") {
@@ -6186,6 +6437,7 @@ async function startWorker(options, projectRuntime = {
6186
6437
  if (activePty && targetMayMutateVisibleWorkspace(activePty.target)) {
6187
6438
  markWorkspaceDirty({ type: "process_terminal", detail: `pty ${message.ptyId} closed` }, true, activePty.target);
6188
6439
  }
6440
+ sendWorkerMessage(ws, { type: "operation_completed", requestId: message.requestId });
6189
6441
  return;
6190
6442
  }
6191
6443
  if (message.type === "exec") {
@@ -6267,7 +6519,11 @@ async function startWorker(options, projectRuntime = {
6267
6519
  } finally {
6268
6520
  if (targetReserved) workspaceSyncPriorityProcessTargets.delete(message.runId);
6269
6521
  }
6270
- ws.send(JSON.stringify(result));
6522
+ if (infrastructureStoppedRunIds.has(message.runId)) {
6523
+ recoveryStore.unknown(message.requestId);
6524
+ return;
6525
+ }
6526
+ sendSerializedWorkerMessage(ws, JSON.stringify(result));
6271
6527
  if (hasWorkspaceEffect && targetMayMutateVisibleWorkspace(message.target)) {
6272
6528
  markWorkspaceDirty(
6273
6529
  { type: "shell_inline", sessionId: message.sessionId, processRunId: message.runId, detail: "foreground command completed" },
@@ -6390,7 +6646,8 @@ async function startWorker(options, projectRuntime = {
6390
6646
  assertAdmission: assertMessageAdmission
6391
6647
  });
6392
6648
  });
6393
- ws.send(
6649
+ sendSerializedWorkerMessage(
6650
+ ws,
6394
6651
  JSON.stringify({
6395
6652
  type: "operation_result",
6396
6653
  requestId: message.requestId,
@@ -6407,7 +6664,8 @@ async function startWorker(options, projectRuntime = {
6407
6664
  markWorkspaceDirty(dirtyTrigger, false, message.target);
6408
6665
  }
6409
6666
  } catch (error) {
6410
- ws.send(
6667
+ sendSerializedWorkerMessage(
6668
+ ws,
6411
6669
  JSON.stringify({
6412
6670
  type: "operation_result",
6413
6671
  requestId: message.requestId,
@@ -6426,7 +6684,8 @@ async function startWorker(options, projectRuntime = {
6426
6684
  }
6427
6685
  const unhandledMessage = message;
6428
6686
  if (typeof unhandledMessage.requestId === "string") {
6429
- ws.send(
6687
+ sendSerializedWorkerMessage(
6688
+ ws,
6430
6689
  JSON.stringify({
6431
6690
  type: "operation_result",
6432
6691
  requestId: unhandledMessage.requestId,
@@ -6440,6 +6699,7 @@ async function startWorker(options, projectRuntime = {
6440
6699
  });
6441
6700
  });
6442
6701
  ws.addEventListener("close", (event) => {
6702
+ clearTimeout(connectionAttemptTimer);
6443
6703
  const processEvents = process;
6444
6704
  processEvents.off("SIGTERM", handleGracefulShutdown);
6445
6705
  processEvents.off("SIGINT", handleGracefulShutdown);
@@ -6456,15 +6716,20 @@ async function startWorker(options, projectRuntime = {
6456
6716
  clearTimeout(deferredWorkspaceConfigurationRefreshTimer);
6457
6717
  deferredWorkspaceConfigurationRefreshTimer = void 0;
6458
6718
  }
6719
+ if (outputReplayTimer) {
6720
+ clearInterval(outputReplayTimer);
6721
+ outputReplayTimer = void 0;
6722
+ }
6459
6723
  if (terminalReplayTimer) {
6460
6724
  clearInterval(terminalReplayTimer);
6461
6725
  terminalReplayTimer = void 0;
6462
6726
  }
6463
- if (currentWorkerSocket === ws) {
6464
- advanceWorkerAdmissionGeneration();
6465
- currentWorkerSocket = null;
6727
+ if (currentWorkerSocket === ws && projectRuntime.configuration) {
6728
+ projectRuntime.configuration.incidentId = activeWorkspaceIncidentId;
6729
+ projectRuntime.configuration.deferred = deferredWorkspaceConfiguration;
6466
6730
  }
6467
- closeAllPtys();
6731
+ if (currentWorkerSocket === ws) currentWorkerSocket = null;
6732
+ workerRecoveryId = "reconnecting";
6468
6733
  const reason = event.reason ? `: ${event.reason}` : "";
6469
6734
  process.stderr.write(`[r5d-worker] disconnected (${event.code}${reason})
6470
6735
  `);
@@ -6474,50 +6739,19 @@ async function startWorker(options, projectRuntime = {
6474
6739
  if (shutdownAfterClose) {
6475
6740
  process.exit(0);
6476
6741
  }
6477
- if (!startupRecoveryComplete) {
6478
- process.stderr.write("[r5d-worker] control connection closed during workspace recovery; finishing recovery before reconnecting\n");
6479
- void startupRecoveryPromise.then(() => {
6480
- if (!startupRecoveryComplete) {
6481
- process.exit(1);
6482
- return;
6483
- }
6484
- const reconnectDelayMs = (0, import_supervisor.workerReconnectDelayMs)();
6485
- process.stderr.write(`[r5d-worker] workspace recovery complete; reconnecting in ${reconnectDelayMs}ms
6742
+ const reconnect = () => {
6743
+ const delay = (0, import_supervisor.workerReconnectDelayMs)(Math.random, workerReconnectAttempt++);
6744
+ process.stderr.write(`[r5d-worker] reconnecting in ${delay}ms
6486
6745
  `);
6487
- setTimeout(() => {
6488
- void startWorker(options, projectRuntime).catch((error) => {
6489
- process.stderr.write(`[r5d-worker] reconnect failed: ${error instanceof Error ? error.message : String(error)}
6490
- `);
6491
- process.exit(error instanceof WorkerServerUnavailableError ? import_supervisor.WORKER_RECONNECT_EXIT_CODE : 1);
6492
- });
6493
- }, reconnectDelayMs);
6494
- });
6495
- return;
6496
- }
6497
- if (activeProcesses.size > 0 || credentialBearingProcessGroups.size > 0 || activePtys.size > 0 || pendingProcessTerminals.size > 0) {
6498
- const scheduleReconnect = (reconnect2) => {
6499
- const reconnectDelayMs = (0, import_supervisor.workerReconnectDelayMs)();
6500
- process.stderr.write(
6501
- `[r5d-worker] ${activeProcesses.size} process(es), ${credentialBearingProcessGroups.size} owned process group(s), and ${activePtys.size} PTY(s) active with ${pendingProcessTerminals.size} terminal report(s) pending; reconnecting in ${reconnectDelayMs}ms
6502
- `
6503
- );
6504
- setTimeout(reconnect2, reconnectDelayMs);
6505
- };
6506
- const reconnect = () => {
6507
- void startWorker(options, projectRuntime).catch((error) => {
6746
+ setTimeout(() => {
6747
+ void startupRecoveryPromise.then(() => startWorker(options, projectRuntime)).catch((error) => {
6508
6748
  process.stderr.write(`[r5d-worker] reconnect failed: ${error instanceof Error ? error.message : String(error)}
6509
6749
  `);
6510
- if (activeProcesses.size > 0 || credentialBearingProcessGroups.size > 0 || activePtys.size > 0 || pendingProcessTerminals.size > 0) {
6511
- scheduleReconnect(reconnect);
6512
- return;
6513
- }
6514
- process.exit(error instanceof WorkerServerUnavailableError ? import_supervisor.WORKER_RECONNECT_EXIT_CODE : 1);
6750
+ reconnect();
6515
6751
  });
6516
- };
6517
- scheduleReconnect(reconnect);
6518
- return;
6519
- }
6520
- process.exit(import_supervisor.WORKER_RECONNECT_EXIT_CODE);
6752
+ }, delay);
6753
+ };
6754
+ reconnect();
6521
6755
  });
6522
6756
  ws.addEventListener("error", (event) => {
6523
6757
  const message = "message" in event && typeof event.message === "string" && event.message.trim() ? `: ${event.message.trim()}` : "";