@ricsam/r5d-worker 0.0.107 → 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
@@ -1,5 +1,14 @@
1
1
  #!/usr/bin/env bun
2
+ import { getWorkerPlanPath, getWorkerPlansPath, validatePlanId } from "./plan-paths.mjs";
3
+ import { createPlanMarkdown, updateTaskCheckbox } from "./plan-parser.mjs";
4
+ import { lintPlans, formatPlanLintErrors } from "./plan-lint.mjs";
2
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";
3
12
  import path from "node:path";
4
13
  import { createHash } from "node:crypto";
5
14
  import os, { hostname } from "node:os";
@@ -169,6 +178,63 @@ const cancelledProcessRuns = /* @__PURE__ */ new Set();
169
178
  const activePtys = /* @__PURE__ */ new Map();
170
179
  const workspaceSyncPriorityPtyTargets = /* @__PURE__ */ new Map();
171
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
+ }
172
238
  let workerAdmissionGeneration = 0;
173
239
  const workspaceMutationGate = new WorkspaceMutationGate();
174
240
  let workspaceSyncQueue = Promise.resolve();
@@ -627,40 +693,46 @@ function parseRemoteFileManifest(value, collectionKey, label) {
627
693
  });
628
694
  }
629
695
  async function fetchRemoteFileManifest(input) {
630
- const response = await fetch(input.url, {
631
- headers: {
632
- 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}` : ""}`);
633
706
  }
707
+ return parseRemoteFileManifest(await response.json(), input.collectionKey, input.label);
634
708
  });
635
- if (!response.ok) {
636
- const detail = (await readResponseText(response)).trim();
637
- throw new Error(`Failed to fetch ${input.label} manifest (${response.status})${detail ? `: ${detail}` : ""}`);
638
- }
639
- return parseRemoteFileManifest(await response.json(), input.collectionKey, input.label);
640
709
  }
641
710
  async function downloadRemoteSyncedFile(input) {
642
- const targetPath = path.join(input.targetDir, input.file.filename);
643
- assertInsideRoot(input.targetDir, targetPath, input.label);
644
- const response = await fetch(input.url, {
645
- headers: {
646
- 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`);
728
+ }
729
+ if (buffer.length !== input.file.size) {
730
+ throw new Error(`Downloaded ${input.label} ${input.file.filename} has unexpected size`);
647
731
  }
732
+ const tempPath = `${targetPath}.${process.pid}.${Date.now()}.tmp`;
733
+ fs.writeFileSync(tempPath, buffer);
734
+ fs.renameSync(tempPath, targetPath);
648
735
  });
649
- if (!response.ok) {
650
- const detail = (await readResponseText(response)).trim();
651
- throw new Error(`Failed to download ${input.label} ${input.file.filename} (${response.status})${detail ? `: ${detail}` : ""}`);
652
- }
653
- const buffer = Buffer.from(await response.arrayBuffer());
654
- const receivedHash = createHash("sha256").update(buffer).digest("hex");
655
- if (receivedHash !== input.file.sha256) {
656
- throw new Error(`Downloaded ${input.label} ${input.file.filename} failed sha256 verification`);
657
- }
658
- if (buffer.length !== input.file.size) {
659
- throw new Error(`Downloaded ${input.label} ${input.file.filename} has unexpected size`);
660
- }
661
- const tempPath = `${targetPath}.${process.pid}.${Date.now()}.tmp`;
662
- fs.writeFileSync(tempPath, buffer);
663
- fs.renameSync(tempPath, targetPath);
664
736
  }
665
737
  async function syncRemoteFileSet(input) {
666
738
  fs.mkdirSync(input.targetDir, { recursive: true });
@@ -787,6 +859,7 @@ async function verifyR5dctlAuth(baseUrl, token, workerLabel) {
787
859
  let response;
788
860
  try {
789
861
  response = await fetch(statusUrl, {
862
+ signal: AbortSignal.timeout(3e4),
790
863
  headers: {
791
864
  Authorization: `Bearer ${token}`
792
865
  }
@@ -845,11 +918,6 @@ function validateProjectId(projectId) {
845
918
  function validateBranchName(branchName) {
846
919
  validateManagedBranchName(branchName);
847
920
  }
848
- function validatePlanId(planId) {
849
- if (!/^[a-z0-9][a-z0-9-]*$/.test(planId)) {
850
- throw new Error(`Invalid plan id from server: ${planId}`);
851
- }
852
- }
853
921
  const NON_RECURSIVE_GIT_CONFIG_ARGS = [
854
922
  "-c",
855
923
  "submodule.recurse=false",
@@ -1756,6 +1824,7 @@ const workerGitSecurityTestHarness = {
1756
1824
  workspaceSyncFailureHydrationIsSafe,
1757
1825
  assertWorkerChildAdmission,
1758
1826
  executeWriteFileOperation,
1827
+ executeOperation,
1759
1828
  executeEditFileOperation,
1760
1829
  terminateCredentialBearingChildren,
1761
1830
  terminateCredentialBearingChildrenWithRetention,
@@ -2194,6 +2263,55 @@ function editWorkerTextFile(branchPath, filePath, edits, builtInPaths) {
2194
2263
  edits: edits.length
2195
2264
  };
2196
2265
  }
2266
+ async function executePlanOperation(input) {
2267
+ const { message, resolvedTarget } = input;
2268
+ const filePath = message.type === "lint_plans" ? getWorkerPlansPath(resolvedTarget.target) : getWorkerPlanPath(resolvedTarget.target, message.planId);
2269
+ const builtInPaths = await prepareBuiltInToolPathsForTarget({
2270
+ target: resolvedTarget.target,
2271
+ filePath,
2272
+ baseUrl: input.baseUrl,
2273
+ token: input.token,
2274
+ rootDir: input.rootDir,
2275
+ sessionId: message.sessionId,
2276
+ access: message.type === "update_task_status" || message.type === "read_plan" && message.createIfMissing ? "write" : "read"
2277
+ });
2278
+ const resolved = resolveWorkerFilePath(resolvedTarget.rootPath, filePath, builtInPaths);
2279
+ if (message.type === "lint_plans") {
2280
+ const result = await lintPlans(resolved.absolutePath, message.glob);
2281
+ return {
2282
+ type: "lint_plans",
2283
+ content: result.lintedPlanCount === 0 ? message.glob ? `No plan files matched glob "${message.glob}".` : "No plan files found." : formatPlanLintErrors(result.errors) || "No plan lint issues found."
2284
+ };
2285
+ }
2286
+ return withFileMutationQueue(mutationQueueKey(resolvedTarget.target, resolved), async () => {
2287
+ input.assertAdmission();
2288
+ if (message.type === "read_plan") {
2289
+ let created = false;
2290
+ if (message.createIfMissing) {
2291
+ try {
2292
+ fs.writeFileSync(resolved.absolutePath, createPlanMarkdown(message.planId), { encoding: "utf8", flag: "wx" });
2293
+ created = true;
2294
+ } catch (error) {
2295
+ if (error.code !== "EEXIST") throw error;
2296
+ }
2297
+ }
2298
+ return {
2299
+ type: "read_plan",
2300
+ created,
2301
+ read: readWorkerTextFile(resolvedTarget.rootPath, filePath, void 0, void 0, builtInPaths)
2302
+ };
2303
+ }
2304
+ const content = fs.readFileSync(resolved.absolutePath, "utf8");
2305
+ writeWorkerTextFile(resolvedTarget.rootPath, filePath, updateTaskCheckbox(content, message.taskId, message.checked), builtInPaths);
2306
+ return {
2307
+ type: "update_task_status",
2308
+ planId: message.planId,
2309
+ taskId: message.taskId,
2310
+ checked: message.checked,
2311
+ message: `Task ${message.taskId} marked ${message.checked ? "complete" : "incomplete"}.`
2312
+ };
2313
+ });
2314
+ }
2197
2315
  async function executeReadFileOperation(input) {
2198
2316
  const builtInPaths = await prepareBuiltInToolPathsForTarget({
2199
2317
  target: input.resolvedTarget.target,
@@ -2572,6 +2690,10 @@ async function executeViewFileBytesOperation(input) {
2572
2690
  }
2573
2691
  async function executeOperation(input) {
2574
2692
  switch (input.message.type) {
2693
+ case "read_plan":
2694
+ case "lint_plans":
2695
+ case "update_task_status":
2696
+ return executePlanOperation({ ...input, message: input.message });
2575
2697
  case "read":
2576
2698
  return executeReadFileOperation({ ...input, message: input.message });
2577
2699
  case "write":
@@ -2901,23 +3023,46 @@ function closeProcessStdin(active) {
2901
3023
  }
2902
3024
  active.stdin = void 0;
2903
3025
  }
2904
- function sendWorkerMessage(ws, message) {
3026
+ function sendReplayWorkerMessage(ws, message) {
2905
3027
  const target = currentWorkerSocket ?? ws;
2906
- try {
2907
- target.send(JSON.stringify(message));
2908
- } catch (error) {
2909
- process.stderr.write(`[r5d-worker] failed to send ${message.type}: ${error instanceof Error ? error.message : String(error)}
2910
- `);
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;
2911
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));
2912
3058
  }
2913
3059
  function sendWorkerMessageFromCurrentSource(ws, message) {
2914
- if (currentWorkerSocket !== ws || ws.readyState !== WebSocket.OPEN) return;
2915
- try {
2916
- ws.send(JSON.stringify(message));
2917
- } catch (error) {
2918
- process.stderr.write(`[r5d-worker] failed to send ${message.type}: ${error instanceof Error ? error.message : String(error)}
2919
- `);
3060
+ if ("requestId" in message && typeof message.requestId === "string" && message.type !== "workspace_configured") {
3061
+ sendWorkerMessage(ws, message);
3062
+ return;
2920
3063
  }
3064
+ if (currentWorkerSocket !== ws || ws.readyState !== WebSocket.OPEN) return;
3065
+ sendWorkerMessage(ws, message);
2921
3066
  }
2922
3067
  function buildActiveProcessReports() {
2923
3068
  return Array.from(activeProcesses.entries()).filter(([, active]) => active.sessionId.length > 0).map(([runId, active]) => ({
@@ -3431,14 +3576,21 @@ function websocketUrl(baseUrl, label) {
3431
3576
  url.searchParams.set("version", getWorkerVersion());
3432
3577
  return url.toString();
3433
3578
  }
3434
- async function startWorker(options) {
3435
- advanceWorkerAdmissionGeneration();
3579
+ async function startWorker(options, projectRuntime = {
3580
+ projectConfigById: /* @__PURE__ */ new Map(),
3581
+ readyProjectIds: /* @__PURE__ */ new Set(),
3582
+ reconciledProjectConfigFingerprints: /* @__PURE__ */ new Map(),
3583
+ gitIdentity: null
3584
+ }) {
3436
3585
  const config = readConfig(options.configPath);
3437
3586
  const { baseUrl, token } = resolveCredentials(options, config);
3438
3587
  initializeCredentialStoreDirectoryOnce();
3439
3588
  const label = requireValue(options.label, "Missing required --label <label>");
3440
3589
  validateLabel(label);
3441
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
+ );
3442
3594
  const projectsRoot = path.join(rootDir, "projects");
3443
3595
  const syncRoot = path.join(rootDir, "sync");
3444
3596
  const workspaceShadowRoot = path.join(syncRoot, "workspace");
@@ -3459,7 +3611,7 @@ async function startWorker(options) {
3459
3611
  `);
3460
3612
  runGit(["--version"]);
3461
3613
  await verifyR5dctlAuth(baseUrl, token, label);
3462
- const initializedWorkspaceState = await workspaceSyncSingleFlight.runExclusive(() => {
3614
+ const initializedWorkspaceState = projectRuntime.initializedWorkspaceState ?? await workspaceSyncSingleFlight.runExclusive(() => {
3463
3615
  fs.mkdirSync(projectsRoot, { recursive: true });
3464
3616
  fs.mkdirSync(syncRoot, { recursive: true });
3465
3617
  fs.mkdirSync(artifactRoot, { recursive: true });
@@ -3470,12 +3622,11 @@ async function startWorker(options) {
3470
3622
  });
3471
3623
  return { store, state: store.read() };
3472
3624
  });
3625
+ projectRuntime.initializedWorkspaceState = initializedWorkspaceState;
3473
3626
  const projectWorkspaceStateStore = initializedWorkspaceState.store;
3474
- let projectWorkspaceState = initializedWorkspaceState.state;
3475
- const projectConfigById = /* @__PURE__ */ new Map();
3627
+ let projectWorkspaceState = initializedWorkspaceState.store.read();
3628
+ const { projectConfigById, readyProjectIds, reconciledProjectConfigFingerprints } = projectRuntime;
3476
3629
  const pendingCheckouts = /* @__PURE__ */ new Map();
3477
- const readyProjectIds = /* @__PURE__ */ new Set();
3478
- const reconciledProjectConfigFingerprints = /* @__PURE__ */ new Map();
3479
3630
  const lastObservedProjectHeads = /* @__PURE__ */ new Map();
3480
3631
  const advertisedProjectMirrorRefsTokens = /* @__PURE__ */ new Map();
3481
3632
  const advertisedProjectMirrorMountTokens = /* @__PURE__ */ new Map();
@@ -3505,7 +3656,7 @@ async function startWorker(options) {
3505
3656
  let workspaceRemoteUrl = null;
3506
3657
  let workspaceCredentialHelper = null;
3507
3658
  let workspaceCredentialUsername = null;
3508
- let workspaceGitIdentity = null;
3659
+ let workspaceGitIdentity = projectRuntime.gitIdentity;
3509
3660
  let workspaceConfigured = false;
3510
3661
  let activeWorkspaceIncidentId = null;
3511
3662
  let deferredWorkspaceConfiguration = null;
@@ -3513,11 +3664,12 @@ async function startWorker(options) {
3513
3664
  let workspaceConfigurationReceiptGeneration = 0;
3514
3665
  let workspaceAutomaticTimer;
3515
3666
  let workspacePeriodicTimer;
3516
- let pendingAutomaticTrigger;
3667
+ let pendingAutomaticTrigger = projectRuntime.pendingAutomaticTrigger;
3517
3668
  const pendingCreatedBranchPublicationNotBefore = /* @__PURE__ */ new Map();
3518
3669
  const workspaceProjectionLedger = new WorkspaceProjectionLedger();
3519
3670
  let automaticSyncInFlight = false;
3520
3671
  let terminalReplayTimer;
3672
+ let outputReplayTimer;
3521
3673
  let workspaceSyncRequestsInFlight = 0;
3522
3674
  let lastWorkspaceSyncMirrorObservationMs = 0;
3523
3675
  let lastInventoriedMissingCheckouts;
@@ -3866,7 +4018,9 @@ async function startWorker(options) {
3866
4018
  const reportedMissingCheckouts = (inventory) => inventory.missingCheckouts.map(({ projectId, branchName, reason }) => ({ projectId, branchName, reason }));
3867
4019
  const logMissingCheckouts = (inventory) => {
3868
4020
  if (inventory.missingCheckouts.length === 0) return;
3869
- const described = inventory.missingCheckouts.map(({ checkoutPath, reason }) => `${checkoutPath} (${reason === "missing" ? "directory missing" : "not a linked worktree of the project"})`).join(", ");
4021
+ const described = inventory.missingCheckouts.map(
4022
+ ({ checkoutPath, reason }) => `${checkoutPath} (${reason === "missing" ? "directory missing" : "not a linked worktree of the project"})`
4023
+ ).join(", ");
3870
4024
  process.stderr.write(
3871
4025
  `[r5d-worker] skipping ${inventory.missingCheckouts.length} configured project checkout(s) this cycle: ${described}; delete the branch through the platform or reconnect this worker to recreate the checkout
3872
4026
  `
@@ -4437,7 +4591,9 @@ async function startWorker(options) {
4437
4591
  error: error instanceof Error ? error.message : String(error)
4438
4592
  };
4439
4593
  };
4440
- const workspaceHydrationTransactionHooks = createWorkspaceHydrationLeaseHooks((message) => sendWorkerMessageFromCurrentSource(ws, message));
4594
+ const workspaceHydrationTransactionHooks = createWorkspaceHydrationLeaseHooks(
4595
+ (message) => sendWorkerMessageFromCurrentSource(ws, message)
4596
+ );
4441
4597
  const performWorkspaceSync = async (input) => {
4442
4598
  if (!workspaceRemoteUrl || !workspaceCredentialHelper || !workspaceCredentialUsername || !workspaceGitIdentity) {
4443
4599
  throw new Error("Worker workspace configuration has not been received");
@@ -4494,7 +4650,11 @@ async function startWorker(options) {
4494
4650
  const outerRemediation = input.trigger.type === "remediation" || input.trigger.type === "remediation_confirm";
4495
4651
  const mounts = outerRemediation ? workspaceGitMountsForRemediation(configuredMounts) : configuredMounts;
4496
4652
  if (outerRemediation) workspaceProjectionLedger.reset();
4497
- const observedProjectHeads = outerRemediation ? [] : await observeProjectHeadsBeforeOuterWorkspace({ allowNonFastForward: false, failClosed: false, projects: checkoutInventory.projects });
4653
+ const observedProjectHeads = outerRemediation ? [] : await observeProjectHeadsBeforeOuterWorkspace({
4654
+ allowNonFastForward: false,
4655
+ failClosed: false,
4656
+ projects: checkoutInventory.projects
4657
+ });
4498
4658
  const expectedMirrorHeads = /* @__PURE__ */ new Map();
4499
4659
  for (const bundle of observedProjectHeads) {
4500
4660
  for (const observation of bundle.observations) {
@@ -4601,7 +4761,7 @@ async function startWorker(options) {
4601
4761
  });
4602
4762
  const workspaceSyncAdmissionSnapshotsMatch = (left, right) => left.generation === right.generation && left.activeIncidentId === right.activeIncidentId && left.deferredIncidentId === right.deferredIncidentId;
4603
4763
  const assertWorkspaceSyncStillAdmitted = (admitted, trigger) => {
4604
- if (!workspaceSyncAdmissionSnapshotsMatch(admitted, captureWorkspaceSyncAdmission()) || currentWorkerSocket !== ws || ws.readyState !== WebSocket.OPEN || !deferredWorkspaceSyncTriggerIsAllowed(deferredWorkspaceConfiguration, activeWorkspaceIncidentId, trigger)) {
4764
+ if (!workspaceSyncAdmissionSnapshotsMatch(admitted, captureWorkspaceSyncAdmission()) || !deferredWorkspaceSyncTriggerIsAllowed(deferredWorkspaceConfiguration, activeWorkspaceIncidentId, trigger)) {
4605
4765
  throw new WorkspaceSyncAdmissionChangedError("Workspace synchronization admission changed while asynchronous work was in flight");
4606
4766
  }
4607
4767
  };
@@ -4655,6 +4815,7 @@ async function startWorker(options) {
4655
4815
  bouncePending: unsafeWorkspaceSyncBouncePending,
4656
4816
  invalidateExecution: () => {
4657
4817
  workspaceConfigured = false;
4818
+ projectRuntime.configuration = void 0;
4658
4819
  advanceWorkerAdmissionGeneration();
4659
4820
  },
4660
4821
  scheduleRecoveryBounce: () => {
@@ -4683,7 +4844,11 @@ async function startWorker(options) {
4683
4844
  };
4684
4845
  }
4685
4846
  };
4686
- const REMEDIATION_TRIGGER_TYPES = /* @__PURE__ */ new Set(["remediation", "remediation_confirm", "remediation_reset"]);
4847
+ const REMEDIATION_TRIGGER_TYPES = /* @__PURE__ */ new Set([
4848
+ "remediation",
4849
+ "remediation_confirm",
4850
+ "remediation_reset"
4851
+ ]);
4687
4852
  const isOrdinaryWorkspaceSyncRequest = (input) => input.confirmedLargeDiff !== true && input.resetToCanonical !== true && !(input.requiredAncestorHeads && input.requiredAncestorHeads.length > 0) && !REMEDIATION_TRIGGER_TYPES.has(input.trigger.type);
4688
4853
  const LOCAL_ACTIVITY_TRIGGER_TYPES = /* @__PURE__ */ new Set([
4689
4854
  "write",
@@ -4724,7 +4889,14 @@ async function startWorker(options) {
4724
4889
  trigger: leader.trigger,
4725
4890
  assertStillAdmitted: () => assertWorkspaceSyncStillAdmitted(admission, leader.trigger)
4726
4891
  });
4727
- return { result, ranCycle: true, startedAtMs, finishedAtMs: Date.now(), prepareMs: lastWorkspaceSyncMirrorObservationMs, staleAttemptIds };
4892
+ return {
4893
+ result,
4894
+ ranCycle: true,
4895
+ startedAtMs,
4896
+ finishedAtMs: Date.now(),
4897
+ prepareMs: lastWorkspaceSyncMirrorObservationMs,
4898
+ staleAttemptIds
4899
+ };
4728
4900
  }
4729
4901
  });
4730
4902
  const runWorkspaceSync = async (input) => {
@@ -4754,7 +4926,7 @@ async function startWorker(options) {
4754
4926
  if (currentWorkerSocket !== ws || ws.readyState !== WebSocket.OPEN) {
4755
4927
  throw new Error("Cannot start autonomous workspace synchronization without an open worker control socket");
4756
4928
  }
4757
- ws.send(JSON.stringify({ type: "workspace_sync_started", attemptId }));
4929
+ sendSerializedWorkerMessage(ws, JSON.stringify({ type: "workspace_sync_started", attemptId }));
4758
4930
  }
4759
4931
  let queueMs = 0;
4760
4932
  let prepareMs = 0;
@@ -4806,6 +4978,7 @@ async function startWorker(options) {
4806
4978
  };
4807
4979
  const scheduleAutomaticWorkspaceSync = (trigger, delayMs = WORKSPACE_GIT_QUIET_MS) => {
4808
4980
  pendingAutomaticTrigger = trigger;
4981
+ projectRuntime.pendingAutomaticTrigger = trigger;
4809
4982
  const pendingBranchDeferral = () => pendingCreatedBranchAutomaticSyncDeferral(
4810
4983
  projectWorkspaceState.locallyPendingCreatedBranches,
4811
4984
  activeWorkspaceMutationTargets(),
@@ -4828,6 +5001,7 @@ async function startWorker(options) {
4828
5001
  return;
4829
5002
  }
4830
5003
  pendingAutomaticTrigger = void 0;
5004
+ projectRuntime.pendingAutomaticTrigger = void 0;
4831
5005
  automaticSyncInFlight = true;
4832
5006
  void runWorkspaceSync({ trigger: scheduledTrigger }).then((result) => {
4833
5007
  if (result.outcome === "failed") {
@@ -4851,9 +5025,14 @@ async function startWorker(options) {
4851
5025
  workspaceAutomaticTimer.unref();
4852
5026
  };
4853
5027
  const markWorkspaceDirty = (trigger, force = false, target) => {
5028
+ if (projectRuntime.markWorkspaceDirty && projectRuntime.markWorkspaceDirty !== markWorkspaceDirty) {
5029
+ projectRuntime.markWorkspaceDirty(trigger, force, target);
5030
+ return;
5031
+ }
4854
5032
  if (target) recordVisibleWorkspaceMutation(target);
4855
5033
  scheduleAutomaticWorkspaceSync(trigger, force ? 0 : WORKSPACE_GIT_QUIET_MS);
4856
5034
  };
5035
+ projectRuntime.markWorkspaceDirty = markWorkspaceDirty;
4857
5036
  const targetMayMutateVisibleWorkspace = (target) => target.type === "project" || target.rootProfile === "visible_projects";
4858
5037
  const resolveMessageTarget = (target) => {
4859
5038
  if (!workspaceConfigured) throw new Error("Worker workspace configuration has not completed successfully");
@@ -5156,6 +5335,7 @@ async function startWorker(options) {
5156
5335
  bootstrapCredentialGenerationFingerprintAtProcessStart = null;
5157
5336
  visibleGitIdentity = message.gitIdentity;
5158
5337
  workspaceGitIdentity = message.gitIdentity;
5338
+ projectRuntime.gitIdentity = message.gitIdentity;
5159
5339
  workspaceRemoteUrl = message.workspaceRemoteUrl;
5160
5340
  workspaceCredentialHelper = nextWorkspaceCredentialHelper;
5161
5341
  workspaceCredentialUsername = workerCredentialUsername;
@@ -5370,6 +5550,10 @@ async function startWorker(options) {
5370
5550
  Authorization: `Bearer ${token}`
5371
5551
  }
5372
5552
  });
5553
+ const connectionAttemptTimer = setTimeout(() => {
5554
+ if (ws.readyState === WebSocket.CONNECTING) ws.terminate();
5555
+ }, 3e4);
5556
+ connectionAttemptTimer.unref();
5373
5557
  let lastServerControlMessageAtMs = performance.now();
5374
5558
  let serverHeartbeatProbeStartedAtMs;
5375
5559
  let lastServerHeartbeatSweepAtMs = performance.now();
@@ -5465,7 +5649,9 @@ async function startWorker(options) {
5465
5649
  process.on("SIGTERM", handleGracefulShutdown);
5466
5650
  process.on("SIGINT", handleGracefulShutdown);
5467
5651
  ws.addEventListener("open", () => {
5652
+ clearTimeout(connectionAttemptTimer);
5468
5653
  currentWorkerSocket = ws;
5654
+ workerRecoveryId = "connecting";
5469
5655
  noteServerControlActivity();
5470
5656
  lastServerHeartbeatSweepAtMs = performance.now();
5471
5657
  serverHeartbeatWatchdog = setInterval(() => {
@@ -5509,6 +5695,9 @@ async function startWorker(options) {
5509
5695
  });
5510
5696
  const hello = {
5511
5697
  type: "hello",
5698
+ resumableProtocol: WORKER_RESUMABLE_PROTOCOL,
5699
+ runtimeId: workerRuntimeId,
5700
+ ledgerId: recoveryStore.ledgerId,
5512
5701
  startupReady: startupRecoveryComplete,
5513
5702
  hostInfo: {
5514
5703
  hostname: hostname(),
@@ -5533,15 +5722,17 @@ async function startWorker(options) {
5533
5722
  root: rootDir
5534
5723
  }
5535
5724
  };
5536
- ws.send(JSON.stringify(hello));
5537
- sendActiveProcessReport(ws);
5538
- for (const terminal of pendingProcessTerminals.values()) {
5539
- sendWorkerMessage(ws, terminal);
5540
- }
5541
- terminalReplayTimer = setInterval(() => {
5542
- for (const terminal of pendingProcessTerminals.values()) {
5543
- 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;
5544
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);
5545
5736
  }, 5e3);
5546
5737
  terminalReplayTimer.unref();
5547
5738
  process.stdout.write("[r5d-worker] connected\n");
@@ -5549,18 +5740,103 @@ async function startWorker(options) {
5549
5740
  ws.addEventListener("message", (event) => {
5550
5741
  noteServerControlActivity();
5551
5742
  void (async () => {
5743
+ if (currentWorkerSocket !== ws) return;
5552
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
+ }
5553
5804
  if (!startupRecoveryComplete && message.type !== "ping") await startupRecoveryPromise;
5554
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
+ }
5555
5821
  const messageAdmissionGeneration = workerAdmissionGeneration;
5556
- const assertMessageAdmission = () => assertWorkerChildAdmission({
5557
- capturedGeneration: messageAdmissionGeneration,
5558
- currentGeneration: workerAdmissionGeneration,
5559
- sourceSocketIsCurrent: currentWorkerSocket === ws,
5560
- sourceSocketOpen: ws.readyState === WebSocket.OPEN,
5561
- workspaceConfigured,
5562
- runtimeHealthy: !shutdownAfterClose
5563
- });
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
+ };
5564
5840
  if (message.type === "connected") {
5565
5841
  return;
5566
5842
  }
@@ -5576,8 +5852,17 @@ async function startWorker(options) {
5576
5852
  if (message.type === "workspace_config") {
5577
5853
  const receiptGeneration = ++workspaceConfigurationReceiptGeneration;
5578
5854
  const refreshesDeferredConfiguration = deferredWorkspaceConfiguration !== null && activeWorkspaceIncidentId === null && message.deferWorkspaceSyncForIncidentId === void 0;
5579
- advanceWorkerAdmissionGeneration();
5580
- 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;
5581
5866
  unsafeWorkspaceSyncBouncePending = false;
5582
5867
  if (configured.result.outcome !== "failed") unsafeWorkspaceSyncRecoveryAttempts = 0;
5583
5868
  sendWorkerMessageFromCurrentSource(ws, {
@@ -5623,16 +5908,31 @@ async function startWorker(options) {
5623
5908
  return;
5624
5909
  }
5625
5910
  if (message.type === "sync_workspace") {
5626
- await runWorkspaceSync({
5627
- requestId: message.requestId,
5628
- attemptId: message.attemptId,
5629
- trigger: message.trigger,
5630
- confirmedLargeDiff: message.confirmedLargeDiff,
5631
- confirmationReason: message.confirmationReason,
5632
- resetToCanonical: message.resetToCanonical,
5633
- requiredAncestorHeads: message.requiredAncestorHeads
5634
- });
5635
- 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
+ }
5636
5936
  }
5637
5937
  if (message.type === "create_project_branch") {
5638
5938
  if (workspaceOperationsAreFenced(deferredWorkspaceConfiguration, activeWorkspaceIncidentId)) {
@@ -5898,6 +6198,7 @@ async function startWorker(options) {
5898
6198
  }
5899
6199
  if (message.type === "exec_terminal_ack") {
5900
6200
  pendingProcessTerminals.delete(message.runId);
6201
+ recoveryStore.acknowledgeTerminal(message.runId);
5901
6202
  return;
5902
6203
  }
5903
6204
  if (message.type === "port_forward_connect") {
@@ -5949,7 +6250,7 @@ async function startWorker(options) {
5949
6250
  return;
5950
6251
  }
5951
6252
  if (message.type === "ping") {
5952
- ws.send(JSON.stringify({ type: "pong" }));
6253
+ sendSerializedWorkerMessage(ws, JSON.stringify({ type: "pong" }));
5953
6254
  return;
5954
6255
  }
5955
6256
  if (message.type === "cancel") {
@@ -5969,7 +6270,8 @@ async function startWorker(options) {
5969
6270
  cancelledProcessRuns.add(message.runId);
5970
6271
  cancelMessage = "Cancellation queued before command start";
5971
6272
  }
5972
- ws.send(
6273
+ sendSerializedWorkerMessage(
6274
+ ws,
5973
6275
  JSON.stringify({
5974
6276
  type: "cancel_result",
5975
6277
  requestId: message.requestId,
@@ -5981,7 +6283,8 @@ async function startWorker(options) {
5981
6283
  return;
5982
6284
  }
5983
6285
  if (message.type === "exec_stdin") {
5984
- const sendAck = (payload) => ws.send(
6286
+ const sendAck = (payload) => sendSerializedWorkerMessage(
6287
+ ws,
5985
6288
  JSON.stringify({
5986
6289
  type: "operation_result",
5987
6290
  requestId: message.requestId,
@@ -6104,16 +6407,19 @@ async function startWorker(options) {
6104
6407
  ptyId: message.ptyId,
6105
6408
  error: `Shell input is deferred for workspace incident ${deferredWorkspaceConfiguration?.incidentId ?? activeWorkspaceIncidentId ?? "unknown"}`
6106
6409
  });
6410
+ sendWorkerMessage(ws, { type: "operation_completed", requestId: message.requestId });
6107
6411
  return;
6108
6412
  }
6109
6413
  if (activePty && targetMayMutateVisibleWorkspace(activePty.target)) {
6110
6414
  markWorkspaceDirty({ type: "shell_inline", detail: `pty ${message.ptyId}` }, false, activePty.target);
6111
6415
  }
6112
6416
  writePty(ws, message);
6417
+ sendWorkerMessage(ws, { type: "operation_completed", requestId: message.requestId });
6113
6418
  return;
6114
6419
  }
6115
6420
  if (message.type === "pty_resize") {
6116
6421
  resizePty(message);
6422
+ sendWorkerMessage(ws, { type: "operation_completed", requestId: message.requestId });
6117
6423
  return;
6118
6424
  }
6119
6425
  if (message.type === "pty_close") {
@@ -6122,6 +6428,7 @@ async function startWorker(options) {
6122
6428
  if (activePty && targetMayMutateVisibleWorkspace(activePty.target)) {
6123
6429
  markWorkspaceDirty({ type: "process_terminal", detail: `pty ${message.ptyId} closed` }, true, activePty.target);
6124
6430
  }
6431
+ sendWorkerMessage(ws, { type: "operation_completed", requestId: message.requestId });
6125
6432
  return;
6126
6433
  }
6127
6434
  if (message.type === "exec") {
@@ -6203,7 +6510,11 @@ async function startWorker(options) {
6203
6510
  } finally {
6204
6511
  if (targetReserved) workspaceSyncPriorityProcessTargets.delete(message.runId);
6205
6512
  }
6206
- 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));
6207
6518
  if (hasWorkspaceEffect && targetMayMutateVisibleWorkspace(message.target)) {
6208
6519
  markWorkspaceDirty(
6209
6520
  { type: "shell_inline", sessionId: message.sessionId, processRunId: message.runId, detail: "foreground command completed" },
@@ -6296,7 +6607,7 @@ async function startWorker(options) {
6296
6607
  });
6297
6608
  return;
6298
6609
  }
6299
- if (message.type === "read" || message.type === "write" || message.type === "edit" || message.type === "grep" || message.type === "find" || message.type === "ls" || message.type === "view_file_bytes" || message.type === "code_list" || message.type === "code_read") {
6610
+ if (message.type === "read_plan" || message.type === "lint_plans" || message.type === "update_task_status" || message.type === "read" || message.type === "write" || message.type === "edit" || message.type === "grep" || message.type === "find" || message.type === "ls" || message.type === "view_file_bytes" || message.type === "code_list" || message.type === "code_read") {
6300
6611
  if (workspaceOperationsAreFenced(deferredWorkspaceConfiguration, activeWorkspaceIncidentId)) {
6301
6612
  sendWorkerMessage(ws, {
6302
6613
  type: "operation_result",
@@ -6306,7 +6617,7 @@ async function startWorker(options) {
6306
6617
  return;
6307
6618
  }
6308
6619
  const reservesVisibleWorkspace = targetMayMutateVisibleWorkspace(message.target);
6309
- const mutatesVisibleWorkspace = (message.type === "write" || message.type === "edit") && targetMayMutateVisibleWorkspace(message.target);
6620
+ const mutatesVisibleWorkspace = (message.type === "write" || message.type === "edit" || message.type === "update_task_status" || message.type === "read_plan" && message.createIfMissing) && targetMayMutateVisibleWorkspace(message.target);
6310
6621
  if (reservesVisibleWorkspace) {
6311
6622
  await reserveWorkspaceCommandAfterCurrentSync(message.target, workspaceSyncSingleFlight, () => {
6312
6623
  workspaceSyncPriorityOperationTargets.set(message.requestId, message.target);
@@ -6326,16 +6637,17 @@ async function startWorker(options) {
6326
6637
  assertAdmission: assertMessageAdmission
6327
6638
  });
6328
6639
  });
6329
- ws.send(
6640
+ sendSerializedWorkerMessage(
6641
+ ws,
6330
6642
  JSON.stringify({
6331
6643
  type: "operation_result",
6332
6644
  requestId: message.requestId,
6333
6645
  result
6334
6646
  })
6335
6647
  );
6336
- if (mutatesVisibleWorkspace) {
6648
+ if (mutatesVisibleWorkspace && (result.type !== "read_plan" || result.created)) {
6337
6649
  dirtyTrigger = {
6338
- type: message.type,
6650
+ type: message.type === "edit" || message.type === "update_task_status" ? "edit" : "write",
6339
6651
  sessionId: message.sessionId,
6340
6652
  toolCallId: message.requestId,
6341
6653
  ...message.target.type === "project" ? { projectId: message.target.projectId, branchName: message.target.branchName } : {}
@@ -6343,7 +6655,8 @@ async function startWorker(options) {
6343
6655
  markWorkspaceDirty(dirtyTrigger, false, message.target);
6344
6656
  }
6345
6657
  } catch (error) {
6346
- ws.send(
6658
+ sendSerializedWorkerMessage(
6659
+ ws,
6347
6660
  JSON.stringify({
6348
6661
  type: "operation_result",
6349
6662
  requestId: message.requestId,
@@ -6362,7 +6675,8 @@ async function startWorker(options) {
6362
6675
  }
6363
6676
  const unhandledMessage = message;
6364
6677
  if (typeof unhandledMessage.requestId === "string") {
6365
- ws.send(
6678
+ sendSerializedWorkerMessage(
6679
+ ws,
6366
6680
  JSON.stringify({
6367
6681
  type: "operation_result",
6368
6682
  requestId: unhandledMessage.requestId,
@@ -6376,6 +6690,7 @@ async function startWorker(options) {
6376
6690
  });
6377
6691
  });
6378
6692
  ws.addEventListener("close", (event) => {
6693
+ clearTimeout(connectionAttemptTimer);
6379
6694
  const processEvents = process;
6380
6695
  processEvents.off("SIGTERM", handleGracefulShutdown);
6381
6696
  processEvents.off("SIGINT", handleGracefulShutdown);
@@ -6392,15 +6707,20 @@ async function startWorker(options) {
6392
6707
  clearTimeout(deferredWorkspaceConfigurationRefreshTimer);
6393
6708
  deferredWorkspaceConfigurationRefreshTimer = void 0;
6394
6709
  }
6710
+ if (outputReplayTimer) {
6711
+ clearInterval(outputReplayTimer);
6712
+ outputReplayTimer = void 0;
6713
+ }
6395
6714
  if (terminalReplayTimer) {
6396
6715
  clearInterval(terminalReplayTimer);
6397
6716
  terminalReplayTimer = void 0;
6398
6717
  }
6399
- if (currentWorkerSocket === ws) {
6400
- advanceWorkerAdmissionGeneration();
6401
- currentWorkerSocket = null;
6718
+ if (projectRuntime.configuration) {
6719
+ projectRuntime.configuration.incidentId = activeWorkspaceIncidentId;
6720
+ projectRuntime.configuration.deferred = deferredWorkspaceConfiguration;
6402
6721
  }
6403
- closeAllPtys();
6722
+ if (currentWorkerSocket === ws) currentWorkerSocket = null;
6723
+ workerRecoveryId = "reconnecting";
6404
6724
  const reason = event.reason ? `: ${event.reason}` : "";
6405
6725
  process.stderr.write(`[r5d-worker] disconnected (${event.code}${reason})
6406
6726
  `);
@@ -6410,50 +6730,19 @@ async function startWorker(options) {
6410
6730
  if (shutdownAfterClose) {
6411
6731
  process.exit(0);
6412
6732
  }
6413
- if (!startupRecoveryComplete) {
6414
- process.stderr.write("[r5d-worker] control connection closed during workspace recovery; finishing recovery before reconnecting\n");
6415
- void startupRecoveryPromise.then(() => {
6416
- if (!startupRecoveryComplete) {
6417
- process.exit(1);
6418
- return;
6419
- }
6420
- const reconnectDelayMs = workerReconnectDelayMs();
6421
- process.stderr.write(`[r5d-worker] workspace recovery complete; reconnecting in ${reconnectDelayMs}ms
6733
+ const reconnect = () => {
6734
+ const delay = workerReconnectDelayMs(Math.random, workerReconnectAttempt++);
6735
+ process.stderr.write(`[r5d-worker] reconnecting in ${delay}ms
6422
6736
  `);
6423
- setTimeout(() => {
6424
- void startWorker(options).catch((error) => {
6425
- process.stderr.write(`[r5d-worker] reconnect failed: ${error instanceof Error ? error.message : String(error)}
6426
- `);
6427
- process.exit(error instanceof WorkerServerUnavailableError ? WORKER_RECONNECT_EXIT_CODE : 1);
6428
- });
6429
- }, reconnectDelayMs);
6430
- });
6431
- return;
6432
- }
6433
- if (activeProcesses.size > 0 || credentialBearingProcessGroups.size > 0 || activePtys.size > 0 || pendingProcessTerminals.size > 0) {
6434
- const scheduleReconnect = (reconnect2) => {
6435
- const reconnectDelayMs = workerReconnectDelayMs();
6436
- process.stderr.write(
6437
- `[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
6438
- `
6439
- );
6440
- setTimeout(reconnect2, reconnectDelayMs);
6441
- };
6442
- const reconnect = () => {
6443
- void startWorker(options).catch((error) => {
6737
+ setTimeout(() => {
6738
+ void startupRecoveryPromise.then(() => startWorker(options, projectRuntime)).catch((error) => {
6444
6739
  process.stderr.write(`[r5d-worker] reconnect failed: ${error instanceof Error ? error.message : String(error)}
6445
6740
  `);
6446
- if (activeProcesses.size > 0 || credentialBearingProcessGroups.size > 0 || activePtys.size > 0 || pendingProcessTerminals.size > 0) {
6447
- scheduleReconnect(reconnect);
6448
- return;
6449
- }
6450
- process.exit(error instanceof WorkerServerUnavailableError ? WORKER_RECONNECT_EXIT_CODE : 1);
6741
+ reconnect();
6451
6742
  });
6452
- };
6453
- scheduleReconnect(reconnect);
6454
- return;
6455
- }
6456
- process.exit(WORKER_RECONNECT_EXIT_CODE);
6743
+ }, delay);
6744
+ };
6745
+ reconnect();
6457
6746
  });
6458
6747
  ws.addEventListener("error", (event) => {
6459
6748
  const message = "message" in event && typeof event.message === "string" && event.message.trim() ? `: ${event.message.trim()}` : "";