@ricsam/r5d-worker 0.0.108 → 0.0.109

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/mjs/main.mjs CHANGED
@@ -3,6 +3,12 @@ import { getWorkerPlanPath, getWorkerPlansPath, validatePlanId } from "./plan-pa
3
3
  import { createPlanMarkdown, updateTaskCheckbox } from "./plan-parser.mjs";
4
4
  import { lintPlans, formatPlanLintErrors } from "./plan-lint.mjs";
5
5
  import fs from "node:fs";
6
+ import { WorkerRecoveryStore, workerOperationDigest } from "./recovery-store.mjs";
7
+ import { WorkerOutputWindow } from "./recovery-output-window.mjs";
8
+ import {
9
+ WORKER_RESUMABLE_PROTOCOL,
10
+ WORKER_RECOVERY_TIMEOUT_MS
11
+ } from "./recovery-protocol.mjs";
6
12
  import path from "node:path";
7
13
  import { createHash } from "node:crypto";
8
14
  import os, { hostname } from "node:os";
@@ -172,6 +178,63 @@ const cancelledProcessRuns = /* @__PURE__ */ new Set();
172
178
  const activePtys = /* @__PURE__ */ new Map();
173
179
  const workspaceSyncPriorityPtyTargets = /* @__PURE__ */ new Map();
174
180
  let currentWorkerSocket = null;
181
+ const workerRuntimeId = crypto.randomUUID();
182
+ const workerOutputWindow = new WorkerOutputWindow();
183
+ let recoveryStore;
184
+ let workerReconnectAttempt = 0;
185
+ let workerRecoveryId;
186
+ let workerExecutionLeaseDeadline = 0;
187
+ let workerExecutionLeaseTimer;
188
+ let workerLeaseExpired = false;
189
+ const infrastructureStoppedRunIds = /* @__PURE__ */ new Set();
190
+ function isWorkerCommunicationFailure(message) {
191
+ 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(
192
+ message
193
+ );
194
+ }
195
+ async function retryWorkerServerRead(read) {
196
+ let deadline;
197
+ let retry = 0;
198
+ while (true) {
199
+ try {
200
+ return await read();
201
+ } catch (error) {
202
+ if (!recoveryStore || !isWorkerCommunicationFailure(error instanceof Error ? error.message : String(error))) throw error;
203
+ deadline ??= Date.now() + WORKER_RECOVERY_TIMEOUT_MS;
204
+ if (workerLeaseExpired || Date.now() >= deadline) throw new WorkerServerUnavailableError("Worker communication recovery expired");
205
+ currentWorkerSocket?.close(1012, "Communication recovery");
206
+ await Bun.sleep(workerReconnectDelayMs(Math.random, retry++));
207
+ }
208
+ }
209
+ }
210
+ function renewWorkerExecutionLease(leaseMs) {
211
+ if (!Number.isFinite(leaseMs) || leaseMs <= 0) return;
212
+ workerLeaseExpired = false;
213
+ workerExecutionLeaseDeadline = Date.now() + Math.min(leaseMs, WORKER_RECOVERY_TIMEOUT_MS);
214
+ if (workerExecutionLeaseTimer) clearTimeout(workerExecutionLeaseTimer);
215
+ workerExecutionLeaseTimer = setTimeout(
216
+ () => {
217
+ if (Date.now() < workerExecutionLeaseDeadline) return;
218
+ workerLeaseExpired = true;
219
+ for (const operation of recoveryStore?.operations() ?? []) {
220
+ if (operation.sessionId && operation.state !== "completed") recoveryStore?.cancelSession(operation.sessionId);
221
+ }
222
+ for (const [runId, active] of activeProcesses) {
223
+ if (!active.sessionId) continue;
224
+ infrastructureStoppedRunIds.add(runId);
225
+ recoveryStore?.unknownRun(runId);
226
+ cancelledProcessRuns.add(runId);
227
+ closeProcessStdin(active);
228
+ void terminateProcessTree(active.process).catch(
229
+ (error) => process.stderr.write(`[r5d-worker] execution lease termination failed: ${error}
230
+ `)
231
+ );
232
+ }
233
+ },
234
+ Math.min(leaseMs, WORKER_RECOVERY_TIMEOUT_MS)
235
+ );
236
+ workerExecutionLeaseTimer.unref();
237
+ }
175
238
  let workerAdmissionGeneration = 0;
176
239
  const workspaceMutationGate = new WorkspaceMutationGate();
177
240
  let workspaceSyncQueue = Promise.resolve();
@@ -630,40 +693,46 @@ function parseRemoteFileManifest(value, collectionKey, label) {
630
693
  });
631
694
  }
632
695
  async function fetchRemoteFileManifest(input) {
633
- const response = await fetch(input.url, {
634
- headers: {
635
- Authorization: `Bearer ${input.token}`
696
+ return retryWorkerServerRead(async () => {
697
+ const response = await fetch(input.url, {
698
+ signal: AbortSignal.timeout(3e4),
699
+ headers: {
700
+ Authorization: `Bearer ${input.token}`
701
+ }
702
+ });
703
+ if (!response.ok) {
704
+ const detail = (await readResponseText(response)).trim();
705
+ throw new Error(`Failed to fetch ${input.label} manifest (${response.status})${detail ? `: ${detail}` : ""}`);
636
706
  }
707
+ return parseRemoteFileManifest(await response.json(), input.collectionKey, input.label);
637
708
  });
638
- if (!response.ok) {
639
- const detail = (await readResponseText(response)).trim();
640
- throw new Error(`Failed to fetch ${input.label} manifest (${response.status})${detail ? `: ${detail}` : ""}`);
641
- }
642
- return parseRemoteFileManifest(await response.json(), input.collectionKey, input.label);
643
709
  }
644
710
  async function downloadRemoteSyncedFile(input) {
645
- const targetPath = path.join(input.targetDir, input.file.filename);
646
- assertInsideRoot(input.targetDir, targetPath, input.label);
647
- const response = await fetch(input.url, {
648
- headers: {
649
- Authorization: `Bearer ${input.token}`
711
+ return retryWorkerServerRead(async () => {
712
+ const targetPath = path.join(input.targetDir, input.file.filename);
713
+ assertInsideRoot(input.targetDir, targetPath, input.label);
714
+ const response = await fetch(input.url, {
715
+ signal: AbortSignal.timeout(3e4),
716
+ headers: {
717
+ Authorization: `Bearer ${input.token}`
718
+ }
719
+ });
720
+ if (!response.ok) {
721
+ const detail = (await readResponseText(response)).trim();
722
+ throw new Error(`Failed to download ${input.label} ${input.file.filename} (${response.status})${detail ? `: ${detail}` : ""}`);
723
+ }
724
+ const buffer = Buffer.from(await response.arrayBuffer());
725
+ const receivedHash = createHash("sha256").update(buffer).digest("hex");
726
+ if (receivedHash !== input.file.sha256) {
727
+ throw new Error(`Downloaded ${input.label} ${input.file.filename} failed sha256 verification`);
650
728
  }
729
+ if (buffer.length !== input.file.size) {
730
+ throw new Error(`Downloaded ${input.label} ${input.file.filename} has unexpected size`);
731
+ }
732
+ const tempPath = `${targetPath}.${process.pid}.${Date.now()}.tmp`;
733
+ fs.writeFileSync(tempPath, buffer);
734
+ fs.renameSync(tempPath, targetPath);
651
735
  });
652
- if (!response.ok) {
653
- const detail = (await readResponseText(response)).trim();
654
- throw new Error(`Failed to download ${input.label} ${input.file.filename} (${response.status})${detail ? `: ${detail}` : ""}`);
655
- }
656
- const buffer = Buffer.from(await response.arrayBuffer());
657
- const receivedHash = createHash("sha256").update(buffer).digest("hex");
658
- if (receivedHash !== input.file.sha256) {
659
- throw new Error(`Downloaded ${input.label} ${input.file.filename} failed sha256 verification`);
660
- }
661
- if (buffer.length !== input.file.size) {
662
- throw new Error(`Downloaded ${input.label} ${input.file.filename} has unexpected size`);
663
- }
664
- const tempPath = `${targetPath}.${process.pid}.${Date.now()}.tmp`;
665
- fs.writeFileSync(tempPath, buffer);
666
- fs.renameSync(tempPath, targetPath);
667
736
  }
668
737
  async function syncRemoteFileSet(input) {
669
738
  fs.mkdirSync(input.targetDir, { recursive: true });
@@ -790,6 +859,7 @@ async function verifyR5dctlAuth(baseUrl, token, workerLabel) {
790
859
  let response;
791
860
  try {
792
861
  response = await fetch(statusUrl, {
862
+ signal: AbortSignal.timeout(3e4),
793
863
  headers: {
794
864
  Authorization: `Bearer ${token}`
795
865
  }
@@ -2953,23 +3023,46 @@ function closeProcessStdin(active) {
2953
3023
  }
2954
3024
  active.stdin = void 0;
2955
3025
  }
2956
- function sendWorkerMessage(ws, message) {
3026
+ function sendReplayWorkerMessage(ws, message) {
2957
3027
  const target = currentWorkerSocket ?? ws;
2958
- try {
2959
- target.send(JSON.stringify(message));
2960
- } catch (error) {
2961
- process.stderr.write(`[r5d-worker] failed to send ${message.type}: ${error instanceof Error ? error.message : String(error)}
2962
- `);
3028
+ if (target.readyState !== WebSocket.OPEN) return false;
3029
+ const send = (payload) => {
3030
+ try {
3031
+ target.send(JSON.stringify(payload));
3032
+ return true;
3033
+ } catch {
3034
+ return false;
3035
+ }
3036
+ };
3037
+ return message.type === "exec_output" ? workerOutputWindow.send(message, send, target.bufferedAmount) : send(message);
3038
+ }
3039
+ function sendWorkerMessage(ws, message) {
3040
+ if (recoveryStore && "requestId" in message && typeof message.requestId === "string" && recoveryStore.isUnknown(message.requestId))
3041
+ return;
3042
+ 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;
3043
+ if (recoveryStore && transportError && isWorkerCommunicationFailure(transportError) && "requestId" in message && typeof message.requestId === "string") {
3044
+ recoveryStore.unknown(message.requestId);
3045
+ currentWorkerSocket?.close(1012, "Communication recovery");
3046
+ return;
2963
3047
  }
3048
+ const recorded = recoveryStore?.record(message) ?? message;
3049
+ if ((message.type === "exec_exit" || message.type === "exec_error") && infrastructureStoppedRunIds.has(message.runId)) {
3050
+ recoveryStore?.unknownRun(message.runId);
3051
+ return;
3052
+ }
3053
+ if (message.type === "exec_output" && workerRecoveryId !== void 0) return;
3054
+ sendReplayWorkerMessage(ws, recorded);
3055
+ }
3056
+ function sendSerializedWorkerMessage(ws, serialized) {
3057
+ sendWorkerMessage(ws, JSON.parse(serialized));
2964
3058
  }
2965
3059
  function sendWorkerMessageFromCurrentSource(ws, message) {
2966
- if (currentWorkerSocket !== ws || ws.readyState !== WebSocket.OPEN) return;
2967
- try {
2968
- ws.send(JSON.stringify(message));
2969
- } catch (error) {
2970
- process.stderr.write(`[r5d-worker] failed to send ${message.type}: ${error instanceof Error ? error.message : String(error)}
2971
- `);
3060
+ if ("requestId" in message && typeof message.requestId === "string" && message.type !== "workspace_configured") {
3061
+ sendWorkerMessage(ws, message);
3062
+ return;
2972
3063
  }
3064
+ if (currentWorkerSocket !== ws || ws.readyState !== WebSocket.OPEN) return;
3065
+ sendWorkerMessage(ws, message);
2973
3066
  }
2974
3067
  function buildActiveProcessReports() {
2975
3068
  return Array.from(activeProcesses.entries()).filter(([, active]) => active.sessionId.length > 0).map(([runId, active]) => ({
@@ -3489,13 +3582,15 @@ async function startWorker(options, projectRuntime = {
3489
3582
  reconciledProjectConfigFingerprints: /* @__PURE__ */ new Map(),
3490
3583
  gitIdentity: null
3491
3584
  }) {
3492
- advanceWorkerAdmissionGeneration();
3493
3585
  const config = readConfig(options.configPath);
3494
3586
  const { baseUrl, token } = resolveCredentials(options, config);
3495
3587
  initializeCredentialStoreDirectoryOnce();
3496
3588
  const label = requireValue(options.label, "Missing required --label <label>");
3497
3589
  validateLabel(label);
3498
3590
  const rootDir = path.resolve(options.rootDir ?? process.env.R5D_ROOT ?? defaultRootDir());
3591
+ recoveryStore ??= new WorkerRecoveryStore(
3592
+ path.join(rootDir, ".worker-recovery", createHash("sha256").update(`${baseUrl}\0${label}`).digest("hex"), "protocol.sqlite")
3593
+ );
3499
3594
  const projectsRoot = path.join(rootDir, "projects");
3500
3595
  const syncRoot = path.join(rootDir, "sync");
3501
3596
  const workspaceShadowRoot = path.join(syncRoot, "workspace");
@@ -3516,7 +3611,7 @@ async function startWorker(options, projectRuntime = {
3516
3611
  `);
3517
3612
  runGit(["--version"]);
3518
3613
  await verifyR5dctlAuth(baseUrl, token, label);
3519
- const initializedWorkspaceState = await workspaceSyncSingleFlight.runExclusive(() => {
3614
+ const initializedWorkspaceState = projectRuntime.initializedWorkspaceState ?? await workspaceSyncSingleFlight.runExclusive(() => {
3520
3615
  fs.mkdirSync(projectsRoot, { recursive: true });
3521
3616
  fs.mkdirSync(syncRoot, { recursive: true });
3522
3617
  fs.mkdirSync(artifactRoot, { recursive: true });
@@ -3527,8 +3622,9 @@ async function startWorker(options, projectRuntime = {
3527
3622
  });
3528
3623
  return { store, state: store.read() };
3529
3624
  });
3625
+ projectRuntime.initializedWorkspaceState = initializedWorkspaceState;
3530
3626
  const projectWorkspaceStateStore = initializedWorkspaceState.store;
3531
- let projectWorkspaceState = initializedWorkspaceState.state;
3627
+ let projectWorkspaceState = initializedWorkspaceState.store.read();
3532
3628
  const { projectConfigById, readyProjectIds, reconciledProjectConfigFingerprints } = projectRuntime;
3533
3629
  const pendingCheckouts = /* @__PURE__ */ new Map();
3534
3630
  const lastObservedProjectHeads = /* @__PURE__ */ new Map();
@@ -3568,11 +3664,12 @@ async function startWorker(options, projectRuntime = {
3568
3664
  let workspaceConfigurationReceiptGeneration = 0;
3569
3665
  let workspaceAutomaticTimer;
3570
3666
  let workspacePeriodicTimer;
3571
- let pendingAutomaticTrigger;
3667
+ let pendingAutomaticTrigger = projectRuntime.pendingAutomaticTrigger;
3572
3668
  const pendingCreatedBranchPublicationNotBefore = /* @__PURE__ */ new Map();
3573
3669
  const workspaceProjectionLedger = new WorkspaceProjectionLedger();
3574
3670
  let automaticSyncInFlight = false;
3575
3671
  let terminalReplayTimer;
3672
+ let outputReplayTimer;
3576
3673
  let workspaceSyncRequestsInFlight = 0;
3577
3674
  let lastWorkspaceSyncMirrorObservationMs = 0;
3578
3675
  let lastInventoriedMissingCheckouts;
@@ -4664,7 +4761,7 @@ async function startWorker(options, projectRuntime = {
4664
4761
  });
4665
4762
  const workspaceSyncAdmissionSnapshotsMatch = (left, right) => left.generation === right.generation && left.activeIncidentId === right.activeIncidentId && left.deferredIncidentId === right.deferredIncidentId;
4666
4763
  const assertWorkspaceSyncStillAdmitted = (admitted, trigger) => {
4667
- if (!workspaceSyncAdmissionSnapshotsMatch(admitted, captureWorkspaceSyncAdmission()) || currentWorkerSocket !== ws || ws.readyState !== WebSocket.OPEN || !deferredWorkspaceSyncTriggerIsAllowed(deferredWorkspaceConfiguration, activeWorkspaceIncidentId, trigger)) {
4764
+ if (!workspaceSyncAdmissionSnapshotsMatch(admitted, captureWorkspaceSyncAdmission()) || !deferredWorkspaceSyncTriggerIsAllowed(deferredWorkspaceConfiguration, activeWorkspaceIncidentId, trigger)) {
4668
4765
  throw new WorkspaceSyncAdmissionChangedError("Workspace synchronization admission changed while asynchronous work was in flight");
4669
4766
  }
4670
4767
  };
@@ -4718,6 +4815,7 @@ async function startWorker(options, projectRuntime = {
4718
4815
  bouncePending: unsafeWorkspaceSyncBouncePending,
4719
4816
  invalidateExecution: () => {
4720
4817
  workspaceConfigured = false;
4818
+ projectRuntime.configuration = void 0;
4721
4819
  advanceWorkerAdmissionGeneration();
4722
4820
  },
4723
4821
  scheduleRecoveryBounce: () => {
@@ -4828,7 +4926,7 @@ async function startWorker(options, projectRuntime = {
4828
4926
  if (currentWorkerSocket !== ws || ws.readyState !== WebSocket.OPEN) {
4829
4927
  throw new Error("Cannot start autonomous workspace synchronization without an open worker control socket");
4830
4928
  }
4831
- ws.send(JSON.stringify({ type: "workspace_sync_started", attemptId }));
4929
+ sendSerializedWorkerMessage(ws, JSON.stringify({ type: "workspace_sync_started", attemptId }));
4832
4930
  }
4833
4931
  let queueMs = 0;
4834
4932
  let prepareMs = 0;
@@ -4880,6 +4978,7 @@ async function startWorker(options, projectRuntime = {
4880
4978
  };
4881
4979
  const scheduleAutomaticWorkspaceSync = (trigger, delayMs = WORKSPACE_GIT_QUIET_MS) => {
4882
4980
  pendingAutomaticTrigger = trigger;
4981
+ projectRuntime.pendingAutomaticTrigger = trigger;
4883
4982
  const pendingBranchDeferral = () => pendingCreatedBranchAutomaticSyncDeferral(
4884
4983
  projectWorkspaceState.locallyPendingCreatedBranches,
4885
4984
  activeWorkspaceMutationTargets(),
@@ -4902,6 +5001,7 @@ async function startWorker(options, projectRuntime = {
4902
5001
  return;
4903
5002
  }
4904
5003
  pendingAutomaticTrigger = void 0;
5004
+ projectRuntime.pendingAutomaticTrigger = void 0;
4905
5005
  automaticSyncInFlight = true;
4906
5006
  void runWorkspaceSync({ trigger: scheduledTrigger }).then((result) => {
4907
5007
  if (result.outcome === "failed") {
@@ -4925,9 +5025,14 @@ async function startWorker(options, projectRuntime = {
4925
5025
  workspaceAutomaticTimer.unref();
4926
5026
  };
4927
5027
  const markWorkspaceDirty = (trigger, force = false, target) => {
5028
+ if (projectRuntime.markWorkspaceDirty && projectRuntime.markWorkspaceDirty !== markWorkspaceDirty) {
5029
+ projectRuntime.markWorkspaceDirty(trigger, force, target);
5030
+ return;
5031
+ }
4928
5032
  if (target) recordVisibleWorkspaceMutation(target);
4929
5033
  scheduleAutomaticWorkspaceSync(trigger, force ? 0 : WORKSPACE_GIT_QUIET_MS);
4930
5034
  };
5035
+ projectRuntime.markWorkspaceDirty = markWorkspaceDirty;
4931
5036
  const targetMayMutateVisibleWorkspace = (target) => target.type === "project" || target.rootProfile === "visible_projects";
4932
5037
  const resolveMessageTarget = (target) => {
4933
5038
  if (!workspaceConfigured) throw new Error("Worker workspace configuration has not completed successfully");
@@ -5445,6 +5550,10 @@ async function startWorker(options, projectRuntime = {
5445
5550
  Authorization: `Bearer ${token}`
5446
5551
  }
5447
5552
  });
5553
+ const connectionAttemptTimer = setTimeout(() => {
5554
+ if (ws.readyState === WebSocket.CONNECTING) ws.terminate();
5555
+ }, 3e4);
5556
+ connectionAttemptTimer.unref();
5448
5557
  let lastServerControlMessageAtMs = performance.now();
5449
5558
  let serverHeartbeatProbeStartedAtMs;
5450
5559
  let lastServerHeartbeatSweepAtMs = performance.now();
@@ -5540,7 +5649,9 @@ async function startWorker(options, projectRuntime = {
5540
5649
  process.on("SIGTERM", handleGracefulShutdown);
5541
5650
  process.on("SIGINT", handleGracefulShutdown);
5542
5651
  ws.addEventListener("open", () => {
5652
+ clearTimeout(connectionAttemptTimer);
5543
5653
  currentWorkerSocket = ws;
5654
+ workerRecoveryId = "connecting";
5544
5655
  noteServerControlActivity();
5545
5656
  lastServerHeartbeatSweepAtMs = performance.now();
5546
5657
  serverHeartbeatWatchdog = setInterval(() => {
@@ -5584,6 +5695,9 @@ async function startWorker(options, projectRuntime = {
5584
5695
  });
5585
5696
  const hello = {
5586
5697
  type: "hello",
5698
+ resumableProtocol: WORKER_RESUMABLE_PROTOCOL,
5699
+ runtimeId: workerRuntimeId,
5700
+ ledgerId: recoveryStore.ledgerId,
5587
5701
  startupReady: startupRecoveryComplete,
5588
5702
  hostInfo: {
5589
5703
  hostname: hostname(),
@@ -5608,15 +5722,17 @@ async function startWorker(options, projectRuntime = {
5608
5722
  root: rootDir
5609
5723
  }
5610
5724
  };
5611
- ws.send(JSON.stringify(hello));
5612
- sendActiveProcessReport(ws);
5613
- for (const terminal of pendingProcessTerminals.values()) {
5614
- sendWorkerMessage(ws, terminal);
5615
- }
5616
- terminalReplayTimer = setInterval(() => {
5617
- for (const terminal of pendingProcessTerminals.values()) {
5618
- sendWorkerMessage(ws, terminal);
5725
+ sendSerializedWorkerMessage(ws, JSON.stringify(hello));
5726
+ outputReplayTimer = setInterval(() => {
5727
+ if (workerRecoveryId !== void 0 || currentWorkerSocket !== ws) return;
5728
+ for (const output of recoveryStore.output()) {
5729
+ if (!sendReplayWorkerMessage(ws, output)) break;
5619
5730
  }
5731
+ }, 250);
5732
+ outputReplayTimer.unref();
5733
+ terminalReplayTimer = setInterval(() => {
5734
+ if (workerRecoveryId !== void 0) return;
5735
+ for (const terminal of recoveryStore?.terminals() ?? []) sendReplayWorkerMessage(ws, terminal);
5620
5736
  }, 5e3);
5621
5737
  terminalReplayTimer.unref();
5622
5738
  process.stdout.write("[r5d-worker] connected\n");
@@ -5624,18 +5740,103 @@ async function startWorker(options, projectRuntime = {
5624
5740
  ws.addEventListener("message", (event) => {
5625
5741
  noteServerControlActivity();
5626
5742
  void (async () => {
5743
+ if (currentWorkerSocket !== ws) return;
5627
5744
  const message = JSON.parse(String(event.data));
5745
+ if (message.type === "recovery_request") {
5746
+ workerRecoveryId = message.recoveryId;
5747
+ sendActiveProcessReport(ws);
5748
+ sendWorkerMessage(ws, {
5749
+ type: "recovery_state",
5750
+ recoveryId: message.recoveryId,
5751
+ runtimeId: workerRuntimeId,
5752
+ ledgerId: recoveryStore.ledgerId,
5753
+ operations: recoveryStore.operations()
5754
+ });
5755
+ const cursors = new Map(recoveryStore.outputCursors().map((cursor) => [cursor.runId, cursor]));
5756
+ for (const cursor of message.outputOffsets) cursors.set(cursor.runId, cursor);
5757
+ workerOutputWindow.reset([...cursors.values()]);
5758
+ for (const response of recoveryStore.responses(message.requests)) sendReplayWorkerMessage(ws, response);
5759
+ for (const output of recoveryStore.output(message.outputOffsets)) {
5760
+ while (true) {
5761
+ if (currentWorkerSocket !== ws || workerRecoveryId !== message.recoveryId || ws.readyState !== WebSocket.OPEN || workerLeaseExpired)
5762
+ return;
5763
+ if (sendReplayWorkerMessage(ws, output)) break;
5764
+ await Bun.sleep(10);
5765
+ }
5766
+ }
5767
+ if (currentWorkerSocket !== ws || workerRecoveryId !== message.recoveryId) return;
5768
+ for (const terminal of recoveryStore.terminals()) sendReplayWorkerMessage(ws, terminal);
5769
+ sendWorkerMessage(ws, { type: "recovery_complete", recoveryId: message.recoveryId });
5770
+ return;
5771
+ }
5772
+ if (message.type === "recovery_ready") {
5773
+ if (workerRecoveryId !== message.recoveryId) return;
5774
+ workerRecoveryId = void 0;
5775
+ workerReconnectAttempt = 0;
5776
+ renewWorkerExecutionLease(message.leaseMs);
5777
+ return;
5778
+ }
5779
+ if (message.type === "worker_execution_lease") {
5780
+ renewWorkerExecutionLease(message.leaseMs);
5781
+ return;
5782
+ }
5783
+ if (message.type === "operation_result_ack") {
5784
+ recoveryStore.acknowledgeResult(message.requestId);
5785
+ return;
5786
+ }
5787
+ if (message.type === "exec_output_ack") {
5788
+ recoveryStore.acknowledgeOutput(message.runId, message.stream, message.nextOffset);
5789
+ workerOutputWindow.acknowledge(message.runId, message.stream, message.nextOffset);
5790
+ return;
5791
+ }
5792
+ if (message.type === "cancel_session") {
5793
+ recoveryStore.cancelSession(message.sessionId);
5794
+ await Promise.all(
5795
+ [...activeProcesses].filter(([, active]) => active.sessionId === message.sessionId).map(async ([runId, active]) => {
5796
+ cancelledProcessRuns.add(runId);
5797
+ closeProcessStdin(active);
5798
+ await terminateProcessTree(active.process);
5799
+ })
5800
+ );
5801
+ sendWorkerMessage(ws, { type: "cancel_session_ack", cancellationId: message.cancellationId });
5802
+ return;
5803
+ }
5628
5804
  if (!startupRecoveryComplete && message.type !== "ping") await startupRecoveryPromise;
5629
5805
  if (!startupRecoveryComplete && message.type !== "ping") return;
5806
+ if (currentWorkerSocket !== ws) return;
5807
+ const operationRequestId = "requestId" in message && typeof message.requestId === "string" && message.type !== "workspace_config" ? message.requestId : void 0;
5808
+ if (operationRequestId) {
5809
+ const admission = recoveryStore.admit({ ...message, requestId: operationRequestId });
5810
+ if (admission === "conflict") {
5811
+ ws.close(1008, "Operation identity conflict");
5812
+ return;
5813
+ }
5814
+ sendWorkerMessage(ws, { type: "operation_received", requestId: operationRequestId });
5815
+ if (admission !== "new") {
5816
+ const response = recoveryStore.response(operationRequestId);
5817
+ if (response && admission !== "unknown") sendReplayWorkerMessage(ws, response);
5818
+ return;
5819
+ }
5820
+ }
5630
5821
  const messageAdmissionGeneration = workerAdmissionGeneration;
5631
- const assertMessageAdmission = () => assertWorkerChildAdmission({
5632
- capturedGeneration: messageAdmissionGeneration,
5633
- currentGeneration: workerAdmissionGeneration,
5634
- sourceSocketIsCurrent: currentWorkerSocket === ws,
5635
- sourceSocketOpen: ws.readyState === WebSocket.OPEN,
5636
- workspaceConfigured,
5637
- runtimeHealthy: !shutdownAfterClose
5638
- });
5822
+ const admissionCredentials = configuredCredentialGenerationFingerprint;
5823
+ const assertMessageAdmission = () => {
5824
+ if (operationRequestId) {
5825
+ if (workerLeaseExpired || recoveryStore.isRequestCancelled(operationRequestId) || admissionCredentials !== configuredCredentialGenerationFingerprint || shutdownAfterClose || !workspaceConfigured) {
5826
+ recoveryStore.unknown(operationRequestId);
5827
+ throw new StaleWorkerAdmissionError();
5828
+ }
5829
+ return;
5830
+ }
5831
+ assertWorkerChildAdmission({
5832
+ capturedGeneration: messageAdmissionGeneration,
5833
+ currentGeneration: workerAdmissionGeneration,
5834
+ sourceSocketIsCurrent: currentWorkerSocket === ws,
5835
+ sourceSocketOpen: ws.readyState === WebSocket.OPEN,
5836
+ workspaceConfigured,
5837
+ runtimeHealthy: !shutdownAfterClose
5838
+ });
5839
+ };
5639
5840
  if (message.type === "connected") {
5640
5841
  return;
5641
5842
  }
@@ -5651,8 +5852,17 @@ async function startWorker(options, projectRuntime = {
5651
5852
  if (message.type === "workspace_config") {
5652
5853
  const receiptGeneration = ++workspaceConfigurationReceiptGeneration;
5653
5854
  const refreshesDeferredConfiguration = deferredWorkspaceConfiguration !== null && activeWorkspaceIncidentId === null && message.deferWorkspaceSyncForIncidentId === void 0;
5654
- advanceWorkerAdmissionGeneration();
5655
- const configured = await configureWorkerWorkspace(message, receiptGeneration);
5855
+ const digest = workerOperationDigest(message);
5856
+ const cached = projectRuntime.configuration;
5857
+ if (cached?.digest !== digest) advanceWorkerAdmissionGeneration();
5858
+ const canReuseConfiguration = cached?.digest === digest && !unsafeWorkspaceSyncBouncePending && credentialGenerationFingerprint({ ...message, workerBaseUrl: baseUrl, workerAuthHeader: bearerAuthHeader }) === configuredCredentialGenerationFingerprint;
5859
+ const configured = canReuseConfiguration ? cached.configured : await configureWorkerWorkspace(message, receiptGeneration);
5860
+ if (canReuseConfiguration && configured.result.outcome !== "failed") {
5861
+ workspaceConfigured = true;
5862
+ activeWorkspaceIncidentId = cached.incidentId;
5863
+ deferredWorkspaceConfiguration = cached.deferred;
5864
+ }
5865
+ projectRuntime.configuration = configured.result.outcome !== "failed" ? { digest, configured, incidentId: activeWorkspaceIncidentId, deferred: deferredWorkspaceConfiguration } : void 0;
5656
5866
  unsafeWorkspaceSyncBouncePending = false;
5657
5867
  if (configured.result.outcome !== "failed") unsafeWorkspaceSyncRecoveryAttempts = 0;
5658
5868
  sendWorkerMessageFromCurrentSource(ws, {
@@ -5698,16 +5908,31 @@ async function startWorker(options, projectRuntime = {
5698
5908
  return;
5699
5909
  }
5700
5910
  if (message.type === "sync_workspace") {
5701
- await runWorkspaceSync({
5702
- requestId: message.requestId,
5703
- attemptId: message.attemptId,
5704
- trigger: message.trigger,
5705
- confirmedLargeDiff: message.confirmedLargeDiff,
5706
- confirmationReason: message.confirmationReason,
5707
- resetToCanonical: message.resetToCanonical,
5708
- requiredAncestorHeads: message.requiredAncestorHeads
5709
- });
5710
- return;
5911
+ let retry = 0;
5912
+ let recoveryDeadline;
5913
+ while (true) {
5914
+ const result = await runWorkspaceSync({
5915
+ requestId: message.requestId,
5916
+ attemptId: message.attemptId,
5917
+ trigger: message.trigger,
5918
+ confirmedLargeDiff: message.confirmedLargeDiff,
5919
+ confirmationReason: message.confirmationReason,
5920
+ resetToCanonical: message.resetToCanonical,
5921
+ requiredAncestorHeads: message.requiredAncestorHeads,
5922
+ sendResult: false
5923
+ });
5924
+ if (result.outcome !== "failed" || !isWorkerCommunicationFailure(result.error ?? "")) {
5925
+ sendWorkspaceSyncResult(message.requestId, result);
5926
+ return;
5927
+ }
5928
+ recoveryDeadline ??= Date.now() + WORKER_RECOVERY_TIMEOUT_MS;
5929
+ if (workerLeaseExpired || Date.now() >= recoveryDeadline) {
5930
+ recoveryStore.unknown(message.requestId);
5931
+ return;
5932
+ }
5933
+ currentWorkerSocket?.close(1012, "Communication recovery");
5934
+ await Bun.sleep(workerReconnectDelayMs(Math.random, retry++));
5935
+ }
5711
5936
  }
5712
5937
  if (message.type === "create_project_branch") {
5713
5938
  if (workspaceOperationsAreFenced(deferredWorkspaceConfiguration, activeWorkspaceIncidentId)) {
@@ -5973,6 +6198,7 @@ async function startWorker(options, projectRuntime = {
5973
6198
  }
5974
6199
  if (message.type === "exec_terminal_ack") {
5975
6200
  pendingProcessTerminals.delete(message.runId);
6201
+ recoveryStore.acknowledgeTerminal(message.runId);
5976
6202
  return;
5977
6203
  }
5978
6204
  if (message.type === "port_forward_connect") {
@@ -6024,7 +6250,7 @@ async function startWorker(options, projectRuntime = {
6024
6250
  return;
6025
6251
  }
6026
6252
  if (message.type === "ping") {
6027
- ws.send(JSON.stringify({ type: "pong" }));
6253
+ sendSerializedWorkerMessage(ws, JSON.stringify({ type: "pong" }));
6028
6254
  return;
6029
6255
  }
6030
6256
  if (message.type === "cancel") {
@@ -6044,7 +6270,8 @@ async function startWorker(options, projectRuntime = {
6044
6270
  cancelledProcessRuns.add(message.runId);
6045
6271
  cancelMessage = "Cancellation queued before command start";
6046
6272
  }
6047
- ws.send(
6273
+ sendSerializedWorkerMessage(
6274
+ ws,
6048
6275
  JSON.stringify({
6049
6276
  type: "cancel_result",
6050
6277
  requestId: message.requestId,
@@ -6056,7 +6283,8 @@ async function startWorker(options, projectRuntime = {
6056
6283
  return;
6057
6284
  }
6058
6285
  if (message.type === "exec_stdin") {
6059
- const sendAck = (payload) => ws.send(
6286
+ const sendAck = (payload) => sendSerializedWorkerMessage(
6287
+ ws,
6060
6288
  JSON.stringify({
6061
6289
  type: "operation_result",
6062
6290
  requestId: message.requestId,
@@ -6179,16 +6407,19 @@ async function startWorker(options, projectRuntime = {
6179
6407
  ptyId: message.ptyId,
6180
6408
  error: `Shell input is deferred for workspace incident ${deferredWorkspaceConfiguration?.incidentId ?? activeWorkspaceIncidentId ?? "unknown"}`
6181
6409
  });
6410
+ sendWorkerMessage(ws, { type: "operation_completed", requestId: message.requestId });
6182
6411
  return;
6183
6412
  }
6184
6413
  if (activePty && targetMayMutateVisibleWorkspace(activePty.target)) {
6185
6414
  markWorkspaceDirty({ type: "shell_inline", detail: `pty ${message.ptyId}` }, false, activePty.target);
6186
6415
  }
6187
6416
  writePty(ws, message);
6417
+ sendWorkerMessage(ws, { type: "operation_completed", requestId: message.requestId });
6188
6418
  return;
6189
6419
  }
6190
6420
  if (message.type === "pty_resize") {
6191
6421
  resizePty(message);
6422
+ sendWorkerMessage(ws, { type: "operation_completed", requestId: message.requestId });
6192
6423
  return;
6193
6424
  }
6194
6425
  if (message.type === "pty_close") {
@@ -6197,6 +6428,7 @@ async function startWorker(options, projectRuntime = {
6197
6428
  if (activePty && targetMayMutateVisibleWorkspace(activePty.target)) {
6198
6429
  markWorkspaceDirty({ type: "process_terminal", detail: `pty ${message.ptyId} closed` }, true, activePty.target);
6199
6430
  }
6431
+ sendWorkerMessage(ws, { type: "operation_completed", requestId: message.requestId });
6200
6432
  return;
6201
6433
  }
6202
6434
  if (message.type === "exec") {
@@ -6278,7 +6510,11 @@ async function startWorker(options, projectRuntime = {
6278
6510
  } finally {
6279
6511
  if (targetReserved) workspaceSyncPriorityProcessTargets.delete(message.runId);
6280
6512
  }
6281
- ws.send(JSON.stringify(result));
6513
+ if (infrastructureStoppedRunIds.has(message.runId)) {
6514
+ recoveryStore.unknown(message.requestId);
6515
+ return;
6516
+ }
6517
+ sendSerializedWorkerMessage(ws, JSON.stringify(result));
6282
6518
  if (hasWorkspaceEffect && targetMayMutateVisibleWorkspace(message.target)) {
6283
6519
  markWorkspaceDirty(
6284
6520
  { type: "shell_inline", sessionId: message.sessionId, processRunId: message.runId, detail: "foreground command completed" },
@@ -6401,7 +6637,8 @@ async function startWorker(options, projectRuntime = {
6401
6637
  assertAdmission: assertMessageAdmission
6402
6638
  });
6403
6639
  });
6404
- ws.send(
6640
+ sendSerializedWorkerMessage(
6641
+ ws,
6405
6642
  JSON.stringify({
6406
6643
  type: "operation_result",
6407
6644
  requestId: message.requestId,
@@ -6418,7 +6655,8 @@ async function startWorker(options, projectRuntime = {
6418
6655
  markWorkspaceDirty(dirtyTrigger, false, message.target);
6419
6656
  }
6420
6657
  } catch (error) {
6421
- ws.send(
6658
+ sendSerializedWorkerMessage(
6659
+ ws,
6422
6660
  JSON.stringify({
6423
6661
  type: "operation_result",
6424
6662
  requestId: message.requestId,
@@ -6437,7 +6675,8 @@ async function startWorker(options, projectRuntime = {
6437
6675
  }
6438
6676
  const unhandledMessage = message;
6439
6677
  if (typeof unhandledMessage.requestId === "string") {
6440
- ws.send(
6678
+ sendSerializedWorkerMessage(
6679
+ ws,
6441
6680
  JSON.stringify({
6442
6681
  type: "operation_result",
6443
6682
  requestId: unhandledMessage.requestId,
@@ -6451,6 +6690,7 @@ async function startWorker(options, projectRuntime = {
6451
6690
  });
6452
6691
  });
6453
6692
  ws.addEventListener("close", (event) => {
6693
+ clearTimeout(connectionAttemptTimer);
6454
6694
  const processEvents = process;
6455
6695
  processEvents.off("SIGTERM", handleGracefulShutdown);
6456
6696
  processEvents.off("SIGINT", handleGracefulShutdown);
@@ -6467,15 +6707,20 @@ async function startWorker(options, projectRuntime = {
6467
6707
  clearTimeout(deferredWorkspaceConfigurationRefreshTimer);
6468
6708
  deferredWorkspaceConfigurationRefreshTimer = void 0;
6469
6709
  }
6710
+ if (outputReplayTimer) {
6711
+ clearInterval(outputReplayTimer);
6712
+ outputReplayTimer = void 0;
6713
+ }
6470
6714
  if (terminalReplayTimer) {
6471
6715
  clearInterval(terminalReplayTimer);
6472
6716
  terminalReplayTimer = void 0;
6473
6717
  }
6474
- if (currentWorkerSocket === ws) {
6475
- advanceWorkerAdmissionGeneration();
6476
- currentWorkerSocket = null;
6718
+ if (projectRuntime.configuration) {
6719
+ projectRuntime.configuration.incidentId = activeWorkspaceIncidentId;
6720
+ projectRuntime.configuration.deferred = deferredWorkspaceConfiguration;
6477
6721
  }
6478
- closeAllPtys();
6722
+ if (currentWorkerSocket === ws) currentWorkerSocket = null;
6723
+ workerRecoveryId = "reconnecting";
6479
6724
  const reason = event.reason ? `: ${event.reason}` : "";
6480
6725
  process.stderr.write(`[r5d-worker] disconnected (${event.code}${reason})
6481
6726
  `);
@@ -6485,50 +6730,19 @@ async function startWorker(options, projectRuntime = {
6485
6730
  if (shutdownAfterClose) {
6486
6731
  process.exit(0);
6487
6732
  }
6488
- if (!startupRecoveryComplete) {
6489
- process.stderr.write("[r5d-worker] control connection closed during workspace recovery; finishing recovery before reconnecting\n");
6490
- void startupRecoveryPromise.then(() => {
6491
- if (!startupRecoveryComplete) {
6492
- process.exit(1);
6493
- return;
6494
- }
6495
- const reconnectDelayMs = workerReconnectDelayMs();
6496
- process.stderr.write(`[r5d-worker] workspace recovery complete; reconnecting in ${reconnectDelayMs}ms
6497
- `);
6498
- setTimeout(() => {
6499
- void startWorker(options, projectRuntime).catch((error) => {
6500
- process.stderr.write(`[r5d-worker] reconnect failed: ${error instanceof Error ? error.message : String(error)}
6733
+ const reconnect = () => {
6734
+ const delay = workerReconnectDelayMs(Math.random, workerReconnectAttempt++);
6735
+ process.stderr.write(`[r5d-worker] reconnecting in ${delay}ms
6501
6736
  `);
6502
- process.exit(error instanceof WorkerServerUnavailableError ? WORKER_RECONNECT_EXIT_CODE : 1);
6503
- });
6504
- }, reconnectDelayMs);
6505
- });
6506
- return;
6507
- }
6508
- if (activeProcesses.size > 0 || credentialBearingProcessGroups.size > 0 || activePtys.size > 0 || pendingProcessTerminals.size > 0) {
6509
- const scheduleReconnect = (reconnect2) => {
6510
- const reconnectDelayMs = workerReconnectDelayMs();
6511
- process.stderr.write(
6512
- `[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
6513
- `
6514
- );
6515
- setTimeout(reconnect2, reconnectDelayMs);
6516
- };
6517
- const reconnect = () => {
6518
- void startWorker(options, projectRuntime).catch((error) => {
6737
+ setTimeout(() => {
6738
+ void startupRecoveryPromise.then(() => startWorker(options, projectRuntime)).catch((error) => {
6519
6739
  process.stderr.write(`[r5d-worker] reconnect failed: ${error instanceof Error ? error.message : String(error)}
6520
6740
  `);
6521
- if (activeProcesses.size > 0 || credentialBearingProcessGroups.size > 0 || activePtys.size > 0 || pendingProcessTerminals.size > 0) {
6522
- scheduleReconnect(reconnect);
6523
- return;
6524
- }
6525
- process.exit(error instanceof WorkerServerUnavailableError ? WORKER_RECONNECT_EXIT_CODE : 1);
6741
+ reconnect();
6526
6742
  });
6527
- };
6528
- scheduleReconnect(reconnect);
6529
- return;
6530
- }
6531
- process.exit(WORKER_RECONNECT_EXIT_CODE);
6743
+ }, delay);
6744
+ };
6745
+ reconnect();
6532
6746
  });
6533
6747
  ws.addEventListener("error", (event) => {
6534
6748
  const message = "message" in event && typeof event.message === "string" && event.message.trim() ? `: ${event.message.trim()}` : "";