@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/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}` : ""}`);
650
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`);
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;
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;
2963
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,11 +5852,15 @@ 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);
5656
- unsafeWorkspaceSyncBouncePending = false;
5657
- if (configured.result.outcome !== "failed") unsafeWorkspaceSyncRecoveryAttempts = 0;
5658
- sendWorkerMessageFromCurrentSource(ws, {
5855
+ const digest = workerOperationDigest(message);
5856
+ const cached = projectRuntime.configuration;
5857
+ 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;
5858
+ if (!canReuseConfiguration) {
5859
+ advanceWorkerAdmissionGeneration();
5860
+ projectRuntime.configuration = void 0;
5861
+ }
5862
+ const configured = canReuseConfiguration ? cached.configured : await configureWorkerWorkspace(message, receiptGeneration);
5863
+ const sendConfigured = () => sendWorkerMessageFromCurrentSource(ws, {
5659
5864
  type: "workspace_configured",
5660
5865
  requestId: message.requestId,
5661
5866
  result: configured.result,
@@ -5663,6 +5868,34 @@ async function startWorker(options, projectRuntime = {
5663
5868
  aheadOfOriginBranches: configured.aheadOfOriginBranches,
5664
5869
  ...configured.deferredWorkspaceSyncForIncidentId ? { deferredWorkspaceSyncForIncidentId: configured.deferredWorkspaceSyncForIncidentId } : {}
5665
5870
  });
5871
+ if (receiptGeneration !== workspaceConfigurationReceiptGeneration || currentWorkerSocket !== ws || ws.readyState !== WebSocket.OPEN) {
5872
+ sendConfigured();
5873
+ return;
5874
+ }
5875
+ if (canReuseConfiguration && configured.result.outcome !== "failed") {
5876
+ workspaceRemoteUrl = cached.workspaceSync.remoteUrl;
5877
+ workspaceCredentialHelper = cached.workspaceSync.credentialHelper;
5878
+ workspaceCredentialUsername = cached.workspaceSync.credentialUsername;
5879
+ workspaceGitIdentity = cached.workspaceSync.gitIdentity;
5880
+ workspaceConfigured = true;
5881
+ activeWorkspaceIncidentId = cached.incidentId;
5882
+ deferredWorkspaceConfiguration = cached.deferred;
5883
+ }
5884
+ projectRuntime.configuration = configured.result.outcome !== "failed" && workspaceRemoteUrl && workspaceCredentialHelper && workspaceCredentialUsername && workspaceGitIdentity ? {
5885
+ digest,
5886
+ workspaceSync: {
5887
+ remoteUrl: workspaceRemoteUrl,
5888
+ credentialHelper: workspaceCredentialHelper,
5889
+ credentialUsername: workspaceCredentialUsername,
5890
+ gitIdentity: workspaceGitIdentity
5891
+ },
5892
+ configured,
5893
+ incidentId: activeWorkspaceIncidentId,
5894
+ deferred: deferredWorkspaceConfiguration
5895
+ } : void 0;
5896
+ unsafeWorkspaceSyncBouncePending = false;
5897
+ if (configured.result.outcome !== "failed") unsafeWorkspaceSyncRecoveryAttempts = 0;
5898
+ sendConfigured();
5666
5899
  sendLifecycleProgress(
5667
5900
  configured.result.outcome === "failed" ? {
5668
5901
  phase: "failed",
@@ -5698,16 +5931,31 @@ async function startWorker(options, projectRuntime = {
5698
5931
  return;
5699
5932
  }
5700
5933
  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;
5934
+ let retry = 0;
5935
+ let recoveryDeadline;
5936
+ while (true) {
5937
+ const result = await runWorkspaceSync({
5938
+ requestId: message.requestId,
5939
+ attemptId: message.attemptId,
5940
+ trigger: message.trigger,
5941
+ confirmedLargeDiff: message.confirmedLargeDiff,
5942
+ confirmationReason: message.confirmationReason,
5943
+ resetToCanonical: message.resetToCanonical,
5944
+ requiredAncestorHeads: message.requiredAncestorHeads,
5945
+ sendResult: false
5946
+ });
5947
+ if (result.outcome !== "failed" || !isWorkerCommunicationFailure(result.error ?? "")) {
5948
+ sendWorkspaceSyncResult(message.requestId, result);
5949
+ return;
5950
+ }
5951
+ recoveryDeadline ??= Date.now() + WORKER_RECOVERY_TIMEOUT_MS;
5952
+ if (workerLeaseExpired || Date.now() >= recoveryDeadline) {
5953
+ recoveryStore.unknown(message.requestId);
5954
+ return;
5955
+ }
5956
+ currentWorkerSocket?.close(1012, "Communication recovery");
5957
+ await Bun.sleep(workerReconnectDelayMs(Math.random, retry++));
5958
+ }
5711
5959
  }
5712
5960
  if (message.type === "create_project_branch") {
5713
5961
  if (workspaceOperationsAreFenced(deferredWorkspaceConfiguration, activeWorkspaceIncidentId)) {
@@ -5973,6 +6221,7 @@ async function startWorker(options, projectRuntime = {
5973
6221
  }
5974
6222
  if (message.type === "exec_terminal_ack") {
5975
6223
  pendingProcessTerminals.delete(message.runId);
6224
+ recoveryStore.acknowledgeTerminal(message.runId);
5976
6225
  return;
5977
6226
  }
5978
6227
  if (message.type === "port_forward_connect") {
@@ -6024,7 +6273,7 @@ async function startWorker(options, projectRuntime = {
6024
6273
  return;
6025
6274
  }
6026
6275
  if (message.type === "ping") {
6027
- ws.send(JSON.stringify({ type: "pong" }));
6276
+ sendSerializedWorkerMessage(ws, JSON.stringify({ type: "pong" }));
6028
6277
  return;
6029
6278
  }
6030
6279
  if (message.type === "cancel") {
@@ -6044,7 +6293,8 @@ async function startWorker(options, projectRuntime = {
6044
6293
  cancelledProcessRuns.add(message.runId);
6045
6294
  cancelMessage = "Cancellation queued before command start";
6046
6295
  }
6047
- ws.send(
6296
+ sendSerializedWorkerMessage(
6297
+ ws,
6048
6298
  JSON.stringify({
6049
6299
  type: "cancel_result",
6050
6300
  requestId: message.requestId,
@@ -6056,7 +6306,8 @@ async function startWorker(options, projectRuntime = {
6056
6306
  return;
6057
6307
  }
6058
6308
  if (message.type === "exec_stdin") {
6059
- const sendAck = (payload) => ws.send(
6309
+ const sendAck = (payload) => sendSerializedWorkerMessage(
6310
+ ws,
6060
6311
  JSON.stringify({
6061
6312
  type: "operation_result",
6062
6313
  requestId: message.requestId,
@@ -6179,16 +6430,19 @@ async function startWorker(options, projectRuntime = {
6179
6430
  ptyId: message.ptyId,
6180
6431
  error: `Shell input is deferred for workspace incident ${deferredWorkspaceConfiguration?.incidentId ?? activeWorkspaceIncidentId ?? "unknown"}`
6181
6432
  });
6433
+ sendWorkerMessage(ws, { type: "operation_completed", requestId: message.requestId });
6182
6434
  return;
6183
6435
  }
6184
6436
  if (activePty && targetMayMutateVisibleWorkspace(activePty.target)) {
6185
6437
  markWorkspaceDirty({ type: "shell_inline", detail: `pty ${message.ptyId}` }, false, activePty.target);
6186
6438
  }
6187
6439
  writePty(ws, message);
6440
+ sendWorkerMessage(ws, { type: "operation_completed", requestId: message.requestId });
6188
6441
  return;
6189
6442
  }
6190
6443
  if (message.type === "pty_resize") {
6191
6444
  resizePty(message);
6445
+ sendWorkerMessage(ws, { type: "operation_completed", requestId: message.requestId });
6192
6446
  return;
6193
6447
  }
6194
6448
  if (message.type === "pty_close") {
@@ -6197,6 +6451,7 @@ async function startWorker(options, projectRuntime = {
6197
6451
  if (activePty && targetMayMutateVisibleWorkspace(activePty.target)) {
6198
6452
  markWorkspaceDirty({ type: "process_terminal", detail: `pty ${message.ptyId} closed` }, true, activePty.target);
6199
6453
  }
6454
+ sendWorkerMessage(ws, { type: "operation_completed", requestId: message.requestId });
6200
6455
  return;
6201
6456
  }
6202
6457
  if (message.type === "exec") {
@@ -6278,7 +6533,11 @@ async function startWorker(options, projectRuntime = {
6278
6533
  } finally {
6279
6534
  if (targetReserved) workspaceSyncPriorityProcessTargets.delete(message.runId);
6280
6535
  }
6281
- ws.send(JSON.stringify(result));
6536
+ if (infrastructureStoppedRunIds.has(message.runId)) {
6537
+ recoveryStore.unknown(message.requestId);
6538
+ return;
6539
+ }
6540
+ sendSerializedWorkerMessage(ws, JSON.stringify(result));
6282
6541
  if (hasWorkspaceEffect && targetMayMutateVisibleWorkspace(message.target)) {
6283
6542
  markWorkspaceDirty(
6284
6543
  { type: "shell_inline", sessionId: message.sessionId, processRunId: message.runId, detail: "foreground command completed" },
@@ -6401,7 +6660,8 @@ async function startWorker(options, projectRuntime = {
6401
6660
  assertAdmission: assertMessageAdmission
6402
6661
  });
6403
6662
  });
6404
- ws.send(
6663
+ sendSerializedWorkerMessage(
6664
+ ws,
6405
6665
  JSON.stringify({
6406
6666
  type: "operation_result",
6407
6667
  requestId: message.requestId,
@@ -6418,7 +6678,8 @@ async function startWorker(options, projectRuntime = {
6418
6678
  markWorkspaceDirty(dirtyTrigger, false, message.target);
6419
6679
  }
6420
6680
  } catch (error) {
6421
- ws.send(
6681
+ sendSerializedWorkerMessage(
6682
+ ws,
6422
6683
  JSON.stringify({
6423
6684
  type: "operation_result",
6424
6685
  requestId: message.requestId,
@@ -6437,7 +6698,8 @@ async function startWorker(options, projectRuntime = {
6437
6698
  }
6438
6699
  const unhandledMessage = message;
6439
6700
  if (typeof unhandledMessage.requestId === "string") {
6440
- ws.send(
6701
+ sendSerializedWorkerMessage(
6702
+ ws,
6441
6703
  JSON.stringify({
6442
6704
  type: "operation_result",
6443
6705
  requestId: unhandledMessage.requestId,
@@ -6451,6 +6713,7 @@ async function startWorker(options, projectRuntime = {
6451
6713
  });
6452
6714
  });
6453
6715
  ws.addEventListener("close", (event) => {
6716
+ clearTimeout(connectionAttemptTimer);
6454
6717
  const processEvents = process;
6455
6718
  processEvents.off("SIGTERM", handleGracefulShutdown);
6456
6719
  processEvents.off("SIGINT", handleGracefulShutdown);
@@ -6467,15 +6730,20 @@ async function startWorker(options, projectRuntime = {
6467
6730
  clearTimeout(deferredWorkspaceConfigurationRefreshTimer);
6468
6731
  deferredWorkspaceConfigurationRefreshTimer = void 0;
6469
6732
  }
6733
+ if (outputReplayTimer) {
6734
+ clearInterval(outputReplayTimer);
6735
+ outputReplayTimer = void 0;
6736
+ }
6470
6737
  if (terminalReplayTimer) {
6471
6738
  clearInterval(terminalReplayTimer);
6472
6739
  terminalReplayTimer = void 0;
6473
6740
  }
6474
- if (currentWorkerSocket === ws) {
6475
- advanceWorkerAdmissionGeneration();
6476
- currentWorkerSocket = null;
6741
+ if (currentWorkerSocket === ws && projectRuntime.configuration) {
6742
+ projectRuntime.configuration.incidentId = activeWorkspaceIncidentId;
6743
+ projectRuntime.configuration.deferred = deferredWorkspaceConfiguration;
6477
6744
  }
6478
- closeAllPtys();
6745
+ if (currentWorkerSocket === ws) currentWorkerSocket = null;
6746
+ workerRecoveryId = "reconnecting";
6479
6747
  const reason = event.reason ? `: ${event.reason}` : "";
6480
6748
  process.stderr.write(`[r5d-worker] disconnected (${event.code}${reason})
6481
6749
  `);
@@ -6485,50 +6753,19 @@ async function startWorker(options, projectRuntime = {
6485
6753
  if (shutdownAfterClose) {
6486
6754
  process.exit(0);
6487
6755
  }
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
6756
+ const reconnect = () => {
6757
+ const delay = workerReconnectDelayMs(Math.random, workerReconnectAttempt++);
6758
+ process.stderr.write(`[r5d-worker] reconnecting in ${delay}ms
6497
6759
  `);
6498
- setTimeout(() => {
6499
- void startWorker(options, projectRuntime).catch((error) => {
6500
- process.stderr.write(`[r5d-worker] reconnect failed: ${error instanceof Error ? error.message : String(error)}
6501
- `);
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) => {
6760
+ setTimeout(() => {
6761
+ void startupRecoveryPromise.then(() => startWorker(options, projectRuntime)).catch((error) => {
6519
6762
  process.stderr.write(`[r5d-worker] reconnect failed: ${error instanceof Error ? error.message : String(error)}
6520
6763
  `);
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);
6764
+ reconnect();
6526
6765
  });
6527
- };
6528
- scheduleReconnect(reconnect);
6529
- return;
6530
- }
6531
- process.exit(WORKER_RECONNECT_EXIT_CODE);
6766
+ }, delay);
6767
+ };
6768
+ reconnect();
6532
6769
  });
6533
6770
  ws.addEventListener("error", (event) => {
6534
6771
  const message = "message" in event && typeof event.message === "string" && event.message.trim() ? `: ${event.message.trim()}` : "";