@ricsam/r5d-worker 0.0.123 → 0.0.125

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.
Files changed (64) hide show
  1. package/README.md +1 -1
  2. package/dist/cjs/command-launcher.cjs +6 -2
  3. package/dist/cjs/main.cjs +496 -238
  4. package/dist/cjs/package.json +1 -1
  5. package/dist/cjs/project-checkout-garbage.cjs +32 -3
  6. package/dist/cjs/project-workspace-state.cjs +51 -35
  7. package/dist/cjs/project-worktrees.cjs +79 -34
  8. package/dist/cjs/recovery-journal-protocol.cjs +56 -0
  9. package/dist/cjs/recovery-journal-runtime.cjs +133 -0
  10. package/dist/cjs/recovery-journal-thread.cjs +9 -0
  11. package/dist/cjs/recovery-journal.cjs +735 -0
  12. package/dist/cjs/recovery-store.cjs +8 -0
  13. package/dist/cjs/session-file-mutations.cjs +61 -0
  14. package/dist/cjs/working-tree-mirror.cjs +1 -0
  15. package/dist/cjs/workspace-command-sync-policy.cjs +37 -8
  16. package/dist/cjs/workspace-filesystem-executor-thread.cjs +36 -0
  17. package/dist/cjs/workspace-filesystem-executor.cjs +327 -0
  18. package/dist/cjs/workspace-filesystem-job-types.cjs +134 -0
  19. package/dist/cjs/workspace-filesystem-jobs.cjs +57 -0
  20. package/dist/cjs/workspace-git-sync.cjs +275 -201
  21. package/dist/cjs/workspace-mount-hold-fence.cjs +120 -0
  22. package/dist/mjs/command-launcher.mjs +6 -2
  23. package/dist/mjs/main.mjs +499 -244
  24. package/dist/mjs/package.json +1 -1
  25. package/dist/mjs/project-checkout-garbage.mjs +30 -2
  26. package/dist/mjs/project-workspace-state.mjs +51 -35
  27. package/dist/mjs/project-worktrees.mjs +74 -34
  28. package/dist/mjs/recovery-journal-protocol.mjs +30 -0
  29. package/dist/mjs/recovery-journal-runtime.mjs +112 -0
  30. package/dist/mjs/recovery-journal-thread.mjs +8 -0
  31. package/dist/mjs/recovery-journal.mjs +687 -0
  32. package/dist/mjs/recovery-store.mjs +8 -0
  33. package/dist/mjs/session-file-mutations.mjs +37 -0
  34. package/dist/mjs/working-tree-mirror.mjs +1 -0
  35. package/dist/mjs/workspace-command-sync-policy.mjs +37 -8
  36. package/dist/mjs/workspace-filesystem-executor-thread.mjs +38 -0
  37. package/dist/mjs/workspace-filesystem-executor.mjs +287 -0
  38. package/dist/mjs/workspace-filesystem-job-types.mjs +106 -0
  39. package/dist/mjs/workspace-filesystem-jobs.mjs +51 -0
  40. package/dist/mjs/workspace-git-sync.mjs +264 -202
  41. package/dist/mjs/workspace-mount-hold-fence.mjs +95 -0
  42. package/dist/types/command-launcher.d.ts +2 -1
  43. package/dist/types/main.d.ts +30 -19
  44. package/dist/types/project-checkout-garbage.d.ts +19 -2
  45. package/dist/types/project-workspace-state.d.ts +9 -9
  46. package/dist/types/project-worktrees.d.ts +49 -5
  47. package/dist/types/recovery-journal-protocol.d.ts +35 -0
  48. package/dist/types/recovery-journal-runtime.d.ts +12 -0
  49. package/dist/types/recovery-journal-stall-fixture.d.ts +1 -0
  50. package/dist/types/recovery-journal-thread.d.ts +1 -0
  51. package/dist/types/recovery-journal.d.ts +246 -0
  52. package/dist/types/recovery-store.d.ts +6 -0
  53. package/dist/types/session-file-mutations.d.ts +22 -0
  54. package/dist/types/workspace-command-sync-policy.d.ts +22 -7
  55. package/dist/types/workspace-filesystem-executor-thread.d.ts +1 -0
  56. package/dist/types/workspace-filesystem-executor.d.ts +123 -0
  57. package/dist/types/workspace-filesystem-job-types.d.ts +246 -0
  58. package/dist/types/workspace-filesystem-jobs.d.ts +7 -0
  59. package/dist/types/workspace-git-sync.d.ts +113 -7
  60. package/dist/types/workspace-mount-hold-fence.d.ts +42 -0
  61. package/package.json +1 -1
  62. package/dist/cjs/project-snapshot-recovery-runner.cjs +0 -171
  63. package/dist/mjs/project-snapshot-recovery-runner.mjs +0 -135
  64. package/dist/types/project-snapshot-recovery-runner.d.ts +0 -10
package/dist/mjs/main.mjs CHANGED
@@ -4,7 +4,9 @@ import { getWorkerPlanPath, getWorkerPlansPath, validatePlanId } from "./plan-pa
4
4
  import { createPlanMarkdown, updateTaskCheckbox } from "./plan-parser.mjs";
5
5
  import { lintPlans, formatPlanLintErrors } from "./plan-lint.mjs";
6
6
  import fs from "node:fs";
7
- import { WorkerRecoveryStore, workerOperationDigest } from "./recovery-store.mjs";
7
+ import { WorkerRecoveryJournal, WorkerRecoveryJournalBusyError } from "./recovery-journal.mjs";
8
+ import { SessionFileMutationTracker } from "./session-file-mutations.mjs";
9
+ import { workerOperationDigest } from "./recovery-store.mjs";
8
10
  import { WorkerOutputWindow } from "./recovery-output-window.mjs";
9
11
  import {
10
12
  createWorkerCommandLauncher,
@@ -58,9 +60,11 @@ import {
58
60
  runWorkspaceCommand
59
61
  } from "./workspace-command-sync-policy.mjs";
60
62
  import { WorkspaceMutationGate } from "./workspace-mutation-gate.mjs";
63
+ import { WorkspaceMountHoldAbortedError, WorkspaceMountHoldFence } from "./workspace-mount-hold-fence.mjs";
64
+ import { installWorkspaceFilesystemExecutorFailureHandler, workspaceFilesystemExecutor } from "./workspace-filesystem-executor.mjs";
61
65
  import { WorkspaceProjectionLedger } from "./workspace-projection-ledger.mjs";
62
66
  import { WorkspaceSyncCoalescer } from "./workspace-sync-coalescer.mjs";
63
- import { checkoutPathMovePublicationComplete, preserveProjectCheckoutPathMove } from "./workspace-path-move.mjs";
67
+ import { checkoutPathMovePublicationComplete } from "./workspace-path-move.mjs";
64
68
  import { busyProjectConfigurationChangeIds, deferredProjectConfigurationPendingBranches } from "./workspace-project-config-policy.mjs";
65
69
  import { creatorLocalProjectBranchIsAuthorized, workerProjectBranchDisposition } from "./workspace-preserve-only-policy.mjs";
66
70
  import { assertProjectBranchDeletionIncarnation } from "./workspace-branch-incarnation-policy.mjs";
@@ -90,6 +94,7 @@ import {
90
94
  deleteProjectMirrorBranch,
91
95
  ensureProjectWorktrees,
92
96
  inventoryProjectCheckouts,
97
+ PROJECT_WORKTREE_SNAPSHOT_OWNER_SESSION_ID,
93
98
  observeProjectMirrorHeads,
94
99
  projectBranchMayExistAfterCreateFailure,
95
100
  projectOriginBranchName,
@@ -102,11 +107,6 @@ import {
102
107
  parseProjectMirrorRefsTokensMessage,
103
108
  planProjectMirrorObservationFetch
104
109
  } from "./project-mirror-fetch-policy.mjs";
105
- import {
106
- PROJECT_SNAPSHOT_RECOVERY_HELPER_COMMAND,
107
- recoverProjectSnapshotsInChild,
108
- runProjectSnapshotRecoveryHelper
109
- } from "./project-snapshot-recovery-runner.mjs";
110
110
  import {
111
111
  ProjectWorkspacePendingBranchPathChangeError,
112
112
  ProjectWorkspaceStateStore,
@@ -135,7 +135,7 @@ function workerServerHeartbeatAction(input) {
135
135
  return input.probeElapsedMs >= input.timeoutMs ? "terminate" : "wait";
136
136
  }
137
137
  const WORKSPACE_HYDRATION_LEASE_OPERATION_PREFIX = "hydration:";
138
- function createWorkspaceHydrationLeaseHooks(send, options = {}) {
138
+ function createWorkspaceHydrationLeaseHooks(send, options) {
139
139
  const yieldToEventLoop = options.yieldToEventLoop ?? (() => new Promise((resolve) => setImmediate(resolve)));
140
140
  const log = options.log ?? ((line) => process.stdout.write(`${line}
141
141
  `));
@@ -156,13 +156,17 @@ function createWorkspaceHydrationLeaseHooks(send, options = {}) {
156
156
  log(
157
157
  `[r5d-worker] released hydration lease ${context.transactionId} after ${startedAt === void 0 ? "?" : Math.round(now() - startedAt)}ms`
158
158
  );
159
+ },
160
+ holdMounts(mountIds, description) {
161
+ return options.fence.hold(mountIds, description);
159
162
  }
160
163
  };
161
164
  }
162
165
  const workerLifecycleTestHarness = {
163
166
  boundedWorkerLifecycleProgress,
164
167
  workerServerHeartbeatAction,
165
- createWorkspaceHydrationLeaseHooks
168
+ createWorkspaceHydrationLeaseHooks,
169
+ workerCommandHasWorkspaceEffect
166
170
  };
167
171
  class ProjectWorkspaceConfigurationDeferredError extends Error {
168
172
  }
@@ -215,7 +219,7 @@ const workspaceSyncPriorityPtyTargets = /* @__PURE__ */ new Map();
215
219
  let currentWorkerSocket = null;
216
220
  const workerRuntimeId = crypto.randomUUID();
217
221
  const workerOutputWindow = new WorkerOutputWindow();
218
- let recoveryStore;
222
+ let recoveryJournal;
219
223
  let workerReconnectAttempt = 0;
220
224
  let workerRecoveryId;
221
225
  let workerExecutionLeaseDeadline = 0;
@@ -227,13 +231,13 @@ let runtimeExitPromise;
227
231
  function fenceWorkerRuntimeOperations() {
228
232
  launcherFailureInProgress = true;
229
233
  workerAdmissionGeneration += 1;
230
- for (const operation of recoveryStore?.operations() ?? []) {
231
- if (operation.state !== "completed") recoveryStore?.unknown(operation.requestId);
234
+ for (const operation of recoveryJournal?.activeAndPendingOperations() ?? []) {
235
+ if (operation.state !== "completed") recoveryJournal?.unknown(operation.requestId);
232
236
  }
233
237
  for (const runId of workerCommandLauncher?.activeIds ?? []) {
234
238
  infrastructureStoppedRunIds.add(runId);
235
239
  cancelledProcessRuns.add(runId);
236
- recoveryStore?.unknownRun(runId);
240
+ recoveryJournal?.unknownRun(runId);
237
241
  }
238
242
  for (const runId of activeProcesses.keys()) infrastructureStoppedRunIds.add(runId);
239
243
  }
@@ -242,13 +246,21 @@ function exitWorkerRuntime(code) {
242
246
  fenceWorkerRuntimeOperations();
243
247
  runtimeExitPromise = (async () => {
244
248
  await Promise.race([
245
- Promise.allSettled([terminateActiveCredentialBearingChildren(), workerCommandLauncher?.close()]),
249
+ Promise.allSettled([terminateActiveCredentialBearingChildren(), workerCommandLauncher?.close(), recoveryJournal?.flush()]),
246
250
  Bun.sleep(4e3)
247
251
  ]);
248
252
  process.exit(code);
249
253
  })();
250
254
  return runtimeExitPromise;
251
255
  }
256
+ function handleRecoveryJournalFailure(error) {
257
+ if (launcherFailureInProgress) return;
258
+ fenceWorkerRuntimeOperations();
259
+ process.stderr.write(`[r5d-worker] ${error.message}; stopping runtime for recovery journal restart
260
+ `);
261
+ currentWorkerSocket?.close(1012, "Recovery journal failure");
262
+ void exitWorkerRuntime(WORKER_RECONNECT_EXIT_CODE);
263
+ }
252
264
  function handleWorkerCommandLauncherFailure(error) {
253
265
  if (!workerCommandLauncher || launcherFailureInProgress) return;
254
266
  fenceWorkerRuntimeOperations();
@@ -269,7 +281,7 @@ async function retryWorkerServerRead(read) {
269
281
  try {
270
282
  return await read();
271
283
  } catch (error) {
272
- if (!recoveryStore || !isWorkerCommunicationFailure(error instanceof Error ? error.message : String(error))) throw error;
284
+ if (!recoveryJournal || !isWorkerCommunicationFailure(error instanceof Error ? error.message : String(error))) throw error;
273
285
  deadline ??= Date.now() + WORKER_RECOVERY_TIMEOUT_MS;
274
286
  if (workerLeaseExpired || Date.now() >= deadline) throw new WorkerServerUnavailableError("Worker communication recovery expired");
275
287
  currentWorkerSocket?.close(1012, "Communication recovery");
@@ -291,9 +303,10 @@ function renewWorkerExecutionLease(leaseMs) {
291
303
  `);
292
304
  });
293
305
  closeAllPtys();
294
- for (const operation of recoveryStore?.operations() ?? []) {
306
+ for (const operation of recoveryJournal?.activeAndPendingOperations() ?? []) {
295
307
  if (operation.sessionId && operation.state !== "completed") {
296
- recoveryStore?.cancelSession(operation.sessionId);
308
+ void recoveryJournal?.cancelSession(operation.sessionId);
309
+ abortPendingReservations("Execution lease expired before command start", operation.sessionId);
297
310
  void workerCommandLauncher?.cancelSession(operation.sessionId).catch((error) => {
298
311
  process.stderr.write(`[r5d-worker] expired command launcher cancellation failed: ${error}
299
312
  `);
@@ -303,7 +316,7 @@ function renewWorkerExecutionLease(leaseMs) {
303
316
  for (const [runId, active] of activeProcesses) {
304
317
  if (!active.sessionId) continue;
305
318
  infrastructureStoppedRunIds.add(runId);
306
- recoveryStore?.unknownRun(runId);
319
+ recoveryJournal?.unknownRun(runId);
307
320
  cancelledProcessRuns.add(runId);
308
321
  closeProcessStdin(active);
309
322
  void terminateProcessTree(active.process).catch(
@@ -318,6 +331,32 @@ function renewWorkerExecutionLease(leaseMs) {
318
331
  }
319
332
  let workerAdmissionGeneration = 0;
320
333
  const workspaceMutationGate = new WorkspaceMutationGate();
334
+ const workspaceMountHoldFence = new WorkspaceMountHoldFence();
335
+ const projectMountId = (projectId, branchName) => `project:${projectId}:${encodeURIComponent(branchName)}`;
336
+ const pendingReservationAborts = /* @__PURE__ */ new Map();
337
+ const sessionFileMutations = new SessionFileMutationTracker();
338
+ function registerPendingReservation(key, sessionId) {
339
+ abortPendingReservation(key, "Reservation superseded by a newer request with the same id");
340
+ const controller = new AbortController();
341
+ pendingReservationAborts.set(key, { controller, sessionId });
342
+ return controller;
343
+ }
344
+ function unregisterPendingReservation(key, controller) {
345
+ if (pendingReservationAborts.get(key)?.controller === controller) pendingReservationAborts.delete(key);
346
+ }
347
+ function abortPendingReservation(key, reason) {
348
+ const pending = pendingReservationAborts.get(key);
349
+ if (!pending) return false;
350
+ pendingReservationAborts.delete(key);
351
+ pending.controller.abort(new WorkspaceMountHoldAbortedError(reason));
352
+ return true;
353
+ }
354
+ function abortPendingReservations(reason, sessionId) {
355
+ for (const [key, pending] of [...pendingReservationAborts]) {
356
+ if (sessionId !== void 0 && pending.sessionId !== sessionId) continue;
357
+ abortPendingReservation(key, reason);
358
+ }
359
+ }
321
360
  let workspaceSyncQueue = Promise.resolve();
322
361
  let startupProjectSnapshotRecoveryCompleted = false;
323
362
  const checkoutGarbageCollectors = /* @__PURE__ */ new Map();
@@ -352,6 +391,17 @@ const workspaceSyncSingleFlight = {
352
391
  },
353
392
  afterCurrent() {
354
393
  return workspaceSyncQueue;
394
+ },
395
+ mountHold(target, signal) {
396
+ if (target.type !== "project") return null;
397
+ const mountIds = [projectMountId(target.projectId, target.branchName)];
398
+ if (!workspaceMountHoldFence.isHeld(mountIds)) return null;
399
+ const holder = workspaceMountHoldFence.describeHold(mountIds);
400
+ process.stdout.write(
401
+ `[r5d-worker] command for ${target.projectId}/${target.branchName} waits for ${holder ?? "a workspace filesystem job"}
402
+ `
403
+ );
404
+ return workspaceMountHoldFence.waitForRelease(mountIds, { signal });
355
405
  }
356
406
  };
357
407
  let githubCredential = null;
@@ -1008,14 +1058,15 @@ async function runGitAsync(args, options = {}) {
1008
1058
  env: workerGitProcessEnvironment(),
1009
1059
  ...bounded ? { detached: true } : {}
1010
1060
  });
1011
- const completion = Promise.all([
1012
- new Response(subprocess.stdout).text(),
1013
- new Response(subprocess.stderr).text(),
1014
- subprocess.exited
1015
- ]).then(([stdout, stderr, exitCode]) => ({ stdout, stderr, exitCode }));
1061
+ const completion = Promise.all([new Response(subprocess.stdout).text(), new Response(subprocess.stderr).text(), subprocess.exited]).then(
1062
+ ([stdout, stderr, exitCode]) => ({ stdout, stderr, exitCode })
1063
+ );
1016
1064
  let timer;
1017
1065
  const timedOut = bounded ? new Promise((_resolve, reject) => {
1018
- timer = setTimeout(() => reject(new GitTransportTimeoutError(`git ${args.join(" ")} timed out after ${options.timeoutMs}ms`)), options.timeoutMs);
1066
+ timer = setTimeout(
1067
+ () => reject(new GitTransportTimeoutError(`git ${args.join(" ")} timed out after ${options.timeoutMs}ms`)),
1068
+ options.timeoutMs
1069
+ );
1019
1070
  }) : null;
1020
1071
  try {
1021
1072
  const { stdout, stderr, exitCode } = timedOut ? await Promise.race([completion, timedOut]) : await completion;
@@ -2104,12 +2155,12 @@ async function streamCommandOutput(stream, onData) {
2104
2155
  }
2105
2156
  const text = decoder.decode(chunk.value, { stream: true });
2106
2157
  if (text.length > 0) {
2107
- onData(text);
2158
+ await onData(text);
2108
2159
  }
2109
2160
  }
2110
2161
  const trailing = decoder.decode();
2111
2162
  if (trailing.length > 0) {
2112
- onData(trailing);
2163
+ await onData(trailing);
2113
2164
  }
2114
2165
  } finally {
2115
2166
  reader.releaseLock();
@@ -2248,12 +2299,21 @@ function mutationQueueKey(target, resolved) {
2248
2299
  }
2249
2300
  return `${describeWorkerSessionTarget(target)}:${path.posix.normalize(resolved.repoRelativePath)}`;
2250
2301
  }
2251
- function readWorkerTextFile(branchPath, filePath, offset, limit, builtInPaths) {
2302
+ async function statIfExists(targetPath) {
2303
+ try {
2304
+ return await fs.promises.stat(targetPath);
2305
+ } catch (error) {
2306
+ const code = error.code;
2307
+ if (code === "ENOENT" || code === "ENOTDIR") return null;
2308
+ throw error;
2309
+ }
2310
+ }
2311
+ async function readWorkerTextFile(branchPath, filePath, offset, limit, builtInPaths) {
2252
2312
  const resolved = resolveWorkerFilePath(branchPath, filePath, builtInPaths);
2253
- if (!fs.existsSync(resolved.absolutePath) || !fs.statSync(resolved.absolutePath).isFile()) {
2313
+ if (!(await statIfExists(resolved.absolutePath))?.isFile()) {
2254
2314
  throw new Error(`File not found: ${resolved.displayPath}`);
2255
2315
  }
2256
- const buffer = fs.readFileSync(resolved.absolutePath);
2316
+ const buffer = await fs.promises.readFile(resolved.absolutePath);
2257
2317
  const formatted = formatLineNumberedContent(buffer.toString("utf8"), offset, limit);
2258
2318
  return {
2259
2319
  type: "read",
@@ -2263,25 +2323,25 @@ function readWorkerTextFile(branchPath, filePath, offset, limit, builtInPaths) {
2263
2323
  ...formatted
2264
2324
  };
2265
2325
  }
2266
- function writeWorkerTextFile(branchPath, filePath, content, builtInPaths) {
2326
+ async function writeWorkerTextFile(branchPath, filePath, content, builtInPaths) {
2267
2327
  const resolved = resolveWorkerFilePath(branchPath, filePath, builtInPaths);
2268
- fs.mkdirSync(path.dirname(resolved.absolutePath), { recursive: true });
2269
- fs.writeFileSync(resolved.absolutePath, content, "utf8");
2328
+ await fs.promises.mkdir(path.dirname(resolved.absolutePath), { recursive: true });
2329
+ await fs.promises.writeFile(resolved.absolutePath, content, "utf8");
2270
2330
  return {
2271
2331
  type: "write",
2272
2332
  file: resolved.displayPath,
2273
2333
  gitBlobHash: getGitBlobHashForContent(content)
2274
2334
  };
2275
2335
  }
2276
- function editWorkerTextFile(branchPath, filePath, edits, builtInPaths) {
2336
+ async function editWorkerTextFile(branchPath, filePath, edits, builtInPaths) {
2277
2337
  const resolved = resolveWorkerFilePath(branchPath, filePath, builtInPaths);
2278
2338
  if (!Array.isArray(edits) || edits.length === 0) {
2279
2339
  throw new Error("edit requires at least one replacement");
2280
2340
  }
2281
- if (!fs.existsSync(resolved.absolutePath) || !fs.statSync(resolved.absolutePath).isFile()) {
2341
+ if (!(await statIfExists(resolved.absolutePath))?.isFile()) {
2282
2342
  throw new Error(`File not found: ${resolved.displayPath}`);
2283
2343
  }
2284
- const originalContent = fs.readFileSync(resolved.absolutePath, "utf8");
2344
+ const originalContent = await fs.promises.readFile(resolved.absolutePath, "utf8");
2285
2345
  const ranges = edits.map((edit, index) => {
2286
2346
  if (edit.oldText === edit.newText) {
2287
2347
  throw new Error(`edits[${index}].oldText and edits[${index}].newText must be different`);
@@ -2313,7 +2373,7 @@ function editWorkerTextFile(branchPath, filePath, edits, builtInPaths) {
2313
2373
  for (const range of [...sortedRanges].reverse()) {
2314
2374
  nextContent = nextContent.slice(0, range.start) + range.edit.newText + nextContent.slice(range.end);
2315
2375
  }
2316
- fs.writeFileSync(resolved.absolutePath, nextContent, "utf8");
2376
+ await fs.promises.writeFile(resolved.absolutePath, nextContent, "utf8");
2317
2377
  return {
2318
2378
  type: "edit",
2319
2379
  file: resolved.displayPath,
@@ -2346,7 +2406,7 @@ async function executePlanOperation(input) {
2346
2406
  let created = false;
2347
2407
  if (message.createIfMissing) {
2348
2408
  try {
2349
- fs.writeFileSync(resolved.absolutePath, createPlanMarkdown(message.planId), { encoding: "utf8", flag: "wx" });
2409
+ await fs.promises.writeFile(resolved.absolutePath, createPlanMarkdown(message.planId), { encoding: "utf8", flag: "wx" });
2350
2410
  created = true;
2351
2411
  } catch (error) {
2352
2412
  if (error.code !== "EEXIST") throw error;
@@ -2355,11 +2415,16 @@ async function executePlanOperation(input) {
2355
2415
  return {
2356
2416
  type: "read_plan",
2357
2417
  created,
2358
- read: readWorkerTextFile(resolvedTarget.rootPath, filePath, void 0, void 0, builtInPaths)
2418
+ read: await readWorkerTextFile(resolvedTarget.rootPath, filePath, void 0, void 0, builtInPaths)
2359
2419
  };
2360
2420
  }
2361
- const content = fs.readFileSync(resolved.absolutePath, "utf8");
2362
- writeWorkerTextFile(resolvedTarget.rootPath, filePath, updateTaskCheckbox(content, message.taskId, message.checked), builtInPaths);
2421
+ const content = await fs.promises.readFile(resolved.absolutePath, "utf8");
2422
+ await writeWorkerTextFile(
2423
+ resolvedTarget.rootPath,
2424
+ filePath,
2425
+ updateTaskCheckbox(content, message.taskId, message.checked),
2426
+ builtInPaths
2427
+ );
2363
2428
  return {
2364
2429
  type: "update_task_status",
2365
2430
  planId: message.planId,
@@ -2380,7 +2445,11 @@ async function executeReadFileOperation(input) {
2380
2445
  rootDir: input.rootDir,
2381
2446
  access: "read"
2382
2447
  });
2383
- return readWorkerTextFile(input.resolvedTarget.rootPath, input.message.filePath, input.message.offset, input.message.limit, builtInPaths);
2448
+ const resolved = resolveWorkerFilePath(input.resolvedTarget.rootPath, input.message.filePath, builtInPaths);
2449
+ return withFileMutationQueue(
2450
+ mutationQueueKey(input.resolvedTarget.target, resolved),
2451
+ () => readWorkerTextFile(input.resolvedTarget.rootPath, input.message.filePath, input.message.offset, input.message.limit, builtInPaths)
2452
+ );
2384
2453
  }
2385
2454
  async function executeWriteFileOperation(input) {
2386
2455
  const builtInPaths = await prepareBuiltInToolPathsForTarget({
@@ -2433,16 +2502,16 @@ function wildcardToRegex(pattern) {
2433
2502
  function normalizeFindPattern(pattern) {
2434
2503
  return /[*?]/.test(pattern) ? pattern : `*${pattern}*`;
2435
2504
  }
2436
- function walkWorkerEntries(branchPath, start) {
2505
+ async function walkWorkerEntries(branchPath, start) {
2437
2506
  const entries = [];
2438
2507
  const visitedDirectories = /* @__PURE__ */ new Set();
2439
- const realVirtualRoot = start.scope === "virtual" ? fs.realpathSync(start.virtualRootPath) : null;
2440
- const visit = (absolutePath, isRoot) => {
2508
+ const realVirtualRoot = start.scope === "virtual" ? await fs.promises.realpath(start.virtualRootPath) : null;
2509
+ const visit = async (absolutePath, isRoot) => {
2441
2510
  let stat;
2442
2511
  try {
2443
- stat = fs.statSync(absolutePath);
2512
+ stat = await fs.promises.stat(absolutePath);
2444
2513
  if (realVirtualRoot) {
2445
- assertInsideRoot(realVirtualRoot, fs.realpathSync(absolutePath), `${start.displayPath} path`);
2514
+ assertInsideRoot(realVirtualRoot, await fs.promises.realpath(absolutePath), `${start.displayPath} path`);
2446
2515
  }
2447
2516
  } catch (error) {
2448
2517
  if (isRoot) throw error;
@@ -2455,7 +2524,7 @@ function walkWorkerEntries(branchPath, start) {
2455
2524
  if (!stat.isDirectory()) return;
2456
2525
  let realDirectoryPath;
2457
2526
  try {
2458
- realDirectoryPath = fs.realpathSync(absolutePath);
2527
+ realDirectoryPath = await fs.promises.realpath(absolutePath);
2459
2528
  } catch (error) {
2460
2529
  if (isRoot) throw error;
2461
2530
  return;
@@ -2464,25 +2533,25 @@ function walkWorkerEntries(branchPath, start) {
2464
2533
  visitedDirectories.add(realDirectoryPath);
2465
2534
  let children;
2466
2535
  try {
2467
- children = fs.readdirSync(absolutePath, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
2536
+ children = (await fs.promises.readdir(absolutePath, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name));
2468
2537
  } catch (error) {
2469
2538
  if (isRoot) throw error;
2470
2539
  return;
2471
2540
  }
2472
2541
  for (const child of children) {
2473
2542
  if (IGNORED_ENTRY_NAMES.has(child.name)) continue;
2474
- visit(path.join(absolutePath, child.name), false);
2543
+ await visit(path.join(absolutePath, child.name), false);
2475
2544
  }
2476
2545
  };
2477
- visit(start.absolutePath, true);
2546
+ await visit(start.absolutePath, true);
2478
2547
  return entries;
2479
2548
  }
2480
2549
  function isProbablyText(bytes) {
2481
2550
  return !bytes.subarray(0, Math.min(bytes.length, 4096)).includes(0);
2482
2551
  }
2483
- function grepWorkerFiles(branchPath, input, builtInPaths) {
2552
+ async function grepWorkerFiles(branchPath, input, builtInPaths) {
2484
2553
  const start = resolveWorkerFilePath(branchPath, input.path ?? ".", builtInPaths);
2485
- if (!fs.existsSync(start.absolutePath)) {
2554
+ if (!await statIfExists(start.absolutePath)) {
2486
2555
  throw new Error(`Path not found: ${start.displayPath}`);
2487
2556
  }
2488
2557
  const limit = normalizeToolLimit(input.limit, 100, 1e3);
@@ -2492,12 +2561,12 @@ function grepWorkerFiles(branchPath, input, builtInPaths) {
2492
2561
  const lines = [];
2493
2562
  let matchCount = 0;
2494
2563
  let truncated = false;
2495
- for (const entry of walkWorkerEntries(branchPath, start)) {
2564
+ for (const entry of await walkWorkerEntries(branchPath, start)) {
2496
2565
  if (!entry.stat.isFile()) continue;
2497
2566
  if (globRegex && !globRegex.test(entry.matchPath)) continue;
2498
2567
  let bytes;
2499
2568
  try {
2500
- bytes = fs.readFileSync(entry.absolutePath);
2569
+ bytes = await fs.promises.readFile(entry.absolutePath);
2501
2570
  } catch {
2502
2571
  continue;
2503
2572
  }
@@ -2550,9 +2619,9 @@ async function executeGrepOperation(input) {
2550
2619
  builtInPaths
2551
2620
  );
2552
2621
  }
2553
- function findWorkerFiles(branchPath, input, builtInPaths) {
2622
+ async function findWorkerFiles(branchPath, input, builtInPaths) {
2554
2623
  const start = resolveWorkerFilePath(branchPath, input.path ?? ".", builtInPaths);
2555
- if (!fs.existsSync(start.absolutePath) || !fs.statSync(start.absolutePath).isDirectory()) {
2624
+ if (!(await statIfExists(start.absolutePath))?.isDirectory()) {
2556
2625
  throw new Error(`Directory not found: ${start.displayPath}`);
2557
2626
  }
2558
2627
  const pattern = normalizeFindPattern(input.pattern ?? "*");
@@ -2562,7 +2631,7 @@ function findWorkerFiles(branchPath, input, builtInPaths) {
2562
2631
  const matches = [];
2563
2632
  let count = 0;
2564
2633
  let truncated = false;
2565
- for (const entry of walkWorkerEntries(branchPath, start)) {
2634
+ for (const entry of await walkWorkerEntries(branchPath, start)) {
2566
2635
  if (entry.absolutePath === start.absolutePath) continue;
2567
2636
  const isDirectory = entry.stat.isDirectory();
2568
2637
  if (entryType === "file" && !entry.stat.isFile()) continue;
@@ -2607,13 +2676,13 @@ async function executeFindOperation(input) {
2607
2676
  builtInPaths
2608
2677
  );
2609
2678
  }
2610
- function listWorkerDirectory(branchPath, inputPath = ".", inputLimit, builtInPaths) {
2679
+ async function listWorkerDirectory(branchPath, inputPath = ".", inputLimit, builtInPaths) {
2611
2680
  const resolved = resolveWorkerFilePath(branchPath, inputPath, builtInPaths);
2612
- if (!fs.existsSync(resolved.absolutePath) || !fs.statSync(resolved.absolutePath).isDirectory()) {
2681
+ if (!(await statIfExists(resolved.absolutePath))?.isDirectory()) {
2613
2682
  throw new Error(`Directory not found: ${resolved.displayPath}`);
2614
2683
  }
2615
2684
  const limit = normalizeToolLimit(inputLimit, 200, 1e3);
2616
- const entries = fs.readdirSync(resolved.absolutePath, { withFileTypes: true }).filter((entry) => !IGNORED_ENTRY_NAMES.has(entry.name)).sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name));
2685
+ const entries = (await fs.promises.readdir(resolved.absolutePath, { withFileTypes: true })).filter((entry) => !IGNORED_ENTRY_NAMES.has(entry.name)).sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name));
2617
2686
  const displayed = entries.slice(0, limit).map((entry) => `${entry.name}${entry.isDirectory() ? "/" : ""}`);
2618
2687
  const truncated = entries.length > displayed.length;
2619
2688
  const hint = truncated ? "\n... truncated. Increase limit to continue." : "";
@@ -2664,36 +2733,40 @@ function resolveWorkerCodePath(branchPath, inputPath) {
2664
2733
  }
2665
2734
  return { absolutePath, displayPath };
2666
2735
  }
2667
- function listWorkerCodeDirectory(branchPath, inputPath) {
2736
+ async function listWorkerCodeDirectory(branchPath, inputPath) {
2668
2737
  const resolved = resolveWorkerCodePath(branchPath, inputPath);
2669
- if (!fs.statSync(resolved.absolutePath).isDirectory()) {
2738
+ if (!(await fs.promises.stat(resolved.absolutePath)).isDirectory()) {
2670
2739
  throw new Error(`Code directory not found: ${resolved.displayPath}`);
2671
2740
  }
2672
- const entries = fs.readdirSync(resolved.absolutePath, { withFileTypes: true }).filter((entry) => entry.name.toLowerCase() !== ".git").flatMap((entry) => {
2741
+ const listed = (await fs.promises.readdir(resolved.absolutePath, { withFileTypes: true })).filter(
2742
+ (entry) => entry.name.toLowerCase() !== ".git"
2743
+ );
2744
+ const entries = [];
2745
+ for (const entry of listed) {
2673
2746
  const entryPath = path.posix.join(resolved.displayPath, entry.name);
2674
2747
  try {
2675
2748
  const resolvedEntry = resolveWorkerCodePath(branchPath, entryPath);
2676
- const stats = fs.statSync(resolvedEntry.absolutePath);
2677
- if (!stats.isFile() && !stats.isDirectory()) return [];
2678
- return [
2679
- {
2680
- name: entry.name,
2681
- type: stats.isDirectory() ? "directory" : "file",
2682
- size: stats.isDirectory() ? null : stats.size,
2683
- mtime: stats.mtime.toISOString()
2684
- }
2685
- ];
2749
+ const stats = await fs.promises.stat(resolvedEntry.absolutePath);
2750
+ if (!stats.isFile() && !stats.isDirectory()) continue;
2751
+ entries.push({
2752
+ name: entry.name,
2753
+ type: stats.isDirectory() ? "directory" : "file",
2754
+ size: stats.isDirectory() ? null : stats.size,
2755
+ mtime: stats.mtime.toISOString()
2756
+ });
2686
2757
  } catch {
2687
- return [];
2688
2758
  }
2689
- }).sort((left, right) => Number(right.type === "directory") - Number(left.type === "directory") || left.name.localeCompare(right.name));
2759
+ }
2760
+ entries.sort(
2761
+ (left, right) => Number(right.type === "directory") - Number(left.type === "directory") || left.name.localeCompare(right.name)
2762
+ );
2690
2763
  return { type: "code_list", path: resolved.displayPath, entries };
2691
2764
  }
2692
- function readWorkerCodeFile(branchPath, inputPath) {
2765
+ async function readWorkerCodeFile(branchPath, inputPath) {
2693
2766
  const resolved = resolveWorkerCodePath(branchPath, inputPath);
2694
- const stats = fs.statSync(resolved.absolutePath);
2767
+ const stats = await fs.promises.stat(resolved.absolutePath);
2695
2768
  if (!stats.isFile()) throw new Error(`Code file not found: ${resolved.displayPath}`);
2696
- const bytes = fs.readFileSync(resolved.absolutePath);
2769
+ const bytes = await fs.promises.readFile(resolved.absolutePath);
2697
2770
  return {
2698
2771
  type: "code_read",
2699
2772
  path: resolved.displayPath,
@@ -2702,17 +2775,17 @@ function readWorkerCodeFile(branchPath, inputPath) {
2702
2775
  base64: bytes.toString("base64")
2703
2776
  };
2704
2777
  }
2705
- function executeCodeListOperation(input) {
2778
+ async function executeCodeListOperation(input) {
2706
2779
  if (input.resolvedTarget.target.type !== "project") throw new Error("Code listing requires a project branch target");
2707
2780
  return listWorkerCodeDirectory(input.resolvedTarget.rootPath, input.message.path);
2708
2781
  }
2709
- function executeCodeReadOperation(input) {
2782
+ async function executeCodeReadOperation(input) {
2710
2783
  if (input.resolvedTarget.target.type !== "project") throw new Error("Code reading requires a project branch target");
2711
2784
  return readWorkerCodeFile(input.resolvedTarget.rootPath, input.message.path);
2712
2785
  }
2713
- function readWorkerImageFile(branchPath, filePath, builtInPaths) {
2786
+ async function readWorkerImageFile(branchPath, filePath, builtInPaths) {
2714
2787
  const resolved = resolveWorkerFilePath(branchPath, filePath, builtInPaths);
2715
- if (!fs.existsSync(resolved.absolutePath) || !fs.statSync(resolved.absolutePath).isFile()) {
2788
+ if (!(await statIfExists(resolved.absolutePath))?.isFile()) {
2716
2789
  throw new Error(`File not found: ${resolved.displayPath}`);
2717
2790
  }
2718
2791
  const extension = path.extname(resolved.absolutePath).toLowerCase();
@@ -2720,7 +2793,7 @@ function readWorkerImageFile(branchPath, filePath, builtInPaths) {
2720
2793
  if (!mediaType) {
2721
2794
  throw new Error("read supports PNG and JPEG image inputs only");
2722
2795
  }
2723
- const bytes = fs.readFileSync(resolved.absolutePath);
2796
+ const bytes = await fs.promises.readFile(resolved.absolutePath);
2724
2797
  const dimensions = readImageDimensions(bytes, mediaType);
2725
2798
  return {
2726
2799
  type: "view_file_bytes",
@@ -3047,26 +3120,28 @@ async function executeStreamingCommand(input) {
3047
3120
  }
3048
3121
  const [exitCode] = await Promise.all([
3049
3122
  subprocess.exited,
3050
- streamCommandOutput(subprocess.stdout, (data) => {
3051
- sendWorkerMessage(input.ws, {
3123
+ streamCommandOutput(
3124
+ subprocess.stdout,
3125
+ (data) => sendWorkerOutput(input.ws, {
3052
3126
  type: "exec_output",
3053
3127
  runId: input.message.runId,
3054
3128
  stream: "stdout",
3055
3129
  data
3056
- });
3057
- }),
3058
- streamCommandOutput(subprocess.stderr, (data) => {
3059
- sendWorkerMessage(input.ws, {
3130
+ })
3131
+ ),
3132
+ streamCommandOutput(
3133
+ subprocess.stderr,
3134
+ (data) => sendWorkerOutput(input.ws, {
3060
3135
  type: "exec_output",
3061
3136
  runId: input.message.runId,
3062
3137
  stream: "stderr",
3063
3138
  data
3064
- });
3065
- })
3139
+ })
3140
+ )
3066
3141
  ]);
3067
3142
  const diagnostic = await input.resources.diagnose();
3068
3143
  if (diagnostic) {
3069
- sendWorkerMessage(input.ws, {
3144
+ await sendWorkerOutput(input.ws, {
3070
3145
  type: "exec_output",
3071
3146
  runId: input.message.runId,
3072
3147
  stream: "stderr",
@@ -3138,6 +3213,39 @@ async function reapCompletedCredentialBearingProcessGroup(runId, subprocess) {
3138
3213
  );
3139
3214
  }
3140
3215
  }
3216
+ async function cancelProcessRun(runId, scope) {
3217
+ const spawned = activeProcesses.get(runId);
3218
+ if (scope === "unstarted" && spawned) return { cancelled: false, message: `${spawned.command} already started`, outcome: "started" };
3219
+ if (!spawned) cancelledProcessRuns.add(runId);
3220
+ const revocation = workerCommandLauncher?.cancel(runId);
3221
+ let resourceCancellationError;
3222
+ let launchRevoked = false;
3223
+ try {
3224
+ launchRevoked = await revocation ?? false;
3225
+ } catch (error) {
3226
+ resourceCancellationError = error instanceof Error ? error.message : String(error);
3227
+ }
3228
+ let cancelled = resourceCancellationError === void 0;
3229
+ let cancelMessage;
3230
+ let outcome;
3231
+ if (spawned) {
3232
+ outcome = "stopped";
3233
+ try {
3234
+ closeProcessStdin(spawned);
3235
+ await terminateProcessTree(spawned.process);
3236
+ cancelMessage = `Stopped ${spawned.command}`;
3237
+ } catch (error) {
3238
+ cancelled = false;
3239
+ cancelMessage = `Failed to stop ${spawned.command}: ${error instanceof Error ? error.message : String(error)}`;
3240
+ }
3241
+ } else {
3242
+ const reservationAborted = abortPendingReservation(`run:${runId}`, "Cancellation queued before command start");
3243
+ outcome = launchRevoked || reservationAborted ? "unstarted" : "unknown";
3244
+ cancelMessage = "Cancellation queued before command start";
3245
+ }
3246
+ if (resourceCancellationError) cancelMessage += `; job resource termination failed: ${resourceCancellationError}`;
3247
+ return { cancelled, message: cancelMessage, outcome };
3248
+ }
3141
3249
  function closeProcessStdin(active) {
3142
3250
  if (!active?.stdin) {
3143
3251
  return;
@@ -3163,21 +3271,35 @@ function sendReplayWorkerMessage(ws, message) {
3163
3271
  }
3164
3272
  function sendWorkerMessage(ws, message) {
3165
3273
  if (launcherFailureInProgress) return;
3166
- if (recoveryStore && "requestId" in message && typeof message.requestId === "string" && recoveryStore.isUnknown(message.requestId))
3167
- return;
3168
3274
  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;
3169
- if (recoveryStore && transportError && isWorkerCommunicationFailure(transportError) && "requestId" in message && typeof message.requestId === "string") {
3170
- recoveryStore.unknown(message.requestId);
3275
+ if (recoveryJournal && transportError && isWorkerCommunicationFailure(transportError) && "requestId" in message && typeof message.requestId === "string") {
3276
+ recoveryJournal.unknown(message.requestId);
3171
3277
  currentWorkerSocket?.close(1012, "Communication recovery");
3172
3278
  return;
3173
3279
  }
3174
- const recorded = recoveryStore?.record(message) ?? message;
3175
- if ((message.type === "exec_exit" || message.type === "exec_error") && infrastructureStoppedRunIds.has(message.runId)) {
3176
- recoveryStore?.unknownRun(message.runId);
3280
+ if (!recoveryJournal) {
3281
+ sendReplayWorkerMessage(ws, message);
3177
3282
  return;
3178
3283
  }
3179
- if (message.type === "exec_output" && workerRecoveryId !== void 0) return;
3180
- sendReplayWorkerMessage(ws, recorded);
3284
+ recoveryJournal.send(message);
3285
+ }
3286
+ function sendWorkerOutput(ws, frame) {
3287
+ if (launcherFailureInProgress) return Promise.resolve();
3288
+ if (!recoveryJournal) {
3289
+ sendReplayWorkerMessage(ws, frame);
3290
+ return Promise.resolve();
3291
+ }
3292
+ return recoveryJournal.sendOutput(frame);
3293
+ }
3294
+ function deliverJournaledWorkerMessage(message) {
3295
+ const target = currentWorkerSocket;
3296
+ if ((message.type === "exec_exit" || message.type === "exec_error") && typeof message.runId === "string" && infrastructureStoppedRunIds.has(message.runId)) {
3297
+ recoveryJournal?.unknownRun(message.runId);
3298
+ return true;
3299
+ }
3300
+ if (!target || target.readyState !== WebSocket.OPEN) return false;
3301
+ if (message.type === "exec_output" && workerRecoveryId !== void 0) return false;
3302
+ return sendReplayWorkerMessage(target, message);
3181
3303
  }
3182
3304
  function sendSerializedWorkerMessage(ws, serialized) {
3183
3305
  sendWorkerMessage(ws, JSON.parse(serialized));
@@ -3740,9 +3862,19 @@ async function startWorker(options, projectRuntime = {
3740
3862
  `);
3741
3863
  }
3742
3864
  const rootDir = path.resolve(options.rootDir ?? process.env.R5D_ROOT ?? defaultRootDir());
3743
- recoveryStore ??= new WorkerRecoveryStore(
3744
- path.join(rootDir, ".worker-recovery", createHash("sha256").update(`${baseUrl}\0${label}`).digest("hex"), "protocol.sqlite")
3745
- );
3865
+ if (!recoveryJournal) {
3866
+ const journal = await WorkerRecoveryJournal.open({
3867
+ filename: path.join(
3868
+ rootDir,
3869
+ ".worker-recovery",
3870
+ createHash("sha256").update(`${baseUrl}\0${label}`).digest("hex"),
3871
+ "protocol.sqlite"
3872
+ )
3873
+ });
3874
+ journal.deliver = deliverJournaledWorkerMessage;
3875
+ journal.onFailure = handleRecoveryJournalFailure;
3876
+ recoveryJournal = journal;
3877
+ }
3746
3878
  const projectsRoot = path.join(rootDir, "projects");
3747
3879
  const syncRoot = path.join(rootDir, "sync");
3748
3880
  const workspaceShadowRoot = path.join(syncRoot, "workspace");
@@ -3845,7 +3977,6 @@ async function startWorker(options, projectRuntime = {
3845
3977
  const bearerAuthHeader = `Authorization: Bearer ${token}`;
3846
3978
  const workerCredentialUsername = credentialUsernameForAuthHeader(bearerAuthHeader);
3847
3979
  const projectBranchKey = (projectId, branchName) => `${projectId}\0${branchName}`;
3848
- const projectMountId = (projectId, branchName) => `project:${projectId}:${encodeURIComponent(branchName)}`;
3849
3980
  const projectPlanMountId = (projectId, branchName) => `plan:${projectId}:${encodeURIComponent(branchName)}`;
3850
3981
  const workspaceProjectRelativePath = (projectId, branchName) => path.posix.join("projects", projectId, "branches", encodeURIComponent(branchName));
3851
3982
  const workspacePlanRelativePath = (projectId, branchName) => path.posix.join("plans", projectId, encodeURIComponent(branchName));
@@ -3928,7 +4059,8 @@ async function startWorker(options, projectRuntime = {
3928
4059
  sourcePath: configuredProjectBranchPath(projectsRoot, project, branchName),
3929
4060
  planSourcePath: path.join(planRoot, project.projectId, ...branchName.split("/"))
3930
4061
  }));
3931
- removeProjectWorktrees({ projectRoot, branchNames: branches.map(({ branchName }) => branchName) });
4062
+ const removed = removeProjectWorktrees({ projectsRoot, projectRoot, branchNames: branches.map(({ branchName }) => branchName) });
4063
+ if (removed.stagedForCollectionPath) checkoutGarbageCollector.enqueue(removed.stagedForCollectionPath);
3932
4064
  for (const branch of branches) {
3933
4065
  fs.rmSync(branch.planSourcePath, { recursive: true, force: true });
3934
4066
  const key = projectBranchKey(project.projectId, branch.branchName);
@@ -4016,14 +4148,14 @@ async function startWorker(options, projectRuntime = {
4016
4148
  )
4017
4149
  };
4018
4150
  };
4019
- const stageDurableWorkspaceDeletions = () => {
4151
+ const stageDurableWorkspaceDeletions = async () => {
4020
4152
  for (const deletion of projectWorkspaceState.pendingTreeDeletions) {
4021
4153
  const cleanupConfig = cleanupConfigForDurableDeletion(deletion);
4022
4154
  if (deletion.kind === "project") {
4023
4155
  if (!deletion.projectDeleted) {
4024
4156
  const nextProject = projectConfigById.get(deletion.projectId);
4025
4157
  if (!nextProject) throw new Error(`Checkout-path move lost desired project ${deletion.projectId}`);
4026
- preserveProjectCheckoutPathMove({
4158
+ await workspaceFilesystemExecutor().run("project_checkout_path_move", {
4027
4159
  oldProjectRoot: deletion.managedPath,
4028
4160
  newProjectRoot: configuredProjectRoot(projectsRoot, nextProject),
4029
4161
  projectsDurabilityRoot: projectsRoot,
@@ -4390,7 +4522,7 @@ async function startWorker(options, projectRuntime = {
4390
4522
  }
4391
4523
  }
4392
4524
  if (transitionReseedRequired) {
4393
- projectWorkspaceState = projectWorkspaceStateStore.recordEnabledRepositoryTransition({
4525
+ projectWorkspaceState = await projectWorkspaceStateStore.recordEnabledRepositoryTransition({
4394
4526
  projectId: project.projectId,
4395
4527
  transitionId: project.repositoryTransitionId
4396
4528
  });
@@ -4559,7 +4691,7 @@ async function startWorker(options, projectRuntime = {
4559
4691
  )
4560
4692
  ];
4561
4693
  if (publishedTombstoneIds.length > 0) {
4562
- projectWorkspaceState = projectWorkspaceStateStore.recordTreePublication({
4694
+ projectWorkspaceState = await projectWorkspaceStateStore.recordTreePublication({
4563
4695
  tombstoneIds: publishedTombstoneIds,
4564
4696
  publishedHead
4565
4697
  });
@@ -4578,7 +4710,7 @@ async function startWorker(options, projectRuntime = {
4578
4710
  forgetLocalProjectMirrorRefsTokens(deletion.projectId, deletion.branchName);
4579
4711
  }
4580
4712
  if (deletion.tombstoneId && !deletion.projectDeleted) {
4581
- projectWorkspaceState = projectWorkspaceStateStore.recordMirrorRefDeletion({
4713
+ projectWorkspaceState = await projectWorkspaceStateStore.recordMirrorRefDeletion({
4582
4714
  tombstoneId: deletion.tombstoneId,
4583
4715
  branchName: deletion.branchName
4584
4716
  });
@@ -4775,7 +4907,10 @@ async function startWorker(options, projectRuntime = {
4775
4907
  };
4776
4908
  };
4777
4909
  const workspaceHydrationTransactionHooks = createWorkspaceHydrationLeaseHooks(
4778
- (message) => sendWorkerMessageFromCurrentSource(ws, message)
4910
+ (message) => sendWorkerMessageFromCurrentSource(ws, message),
4911
+ {
4912
+ fence: workspaceMountHoldFence
4913
+ }
4779
4914
  );
4780
4915
  const performWorkspaceSync = async (input) => {
4781
4916
  if (!workspaceRemoteUrl || !workspaceCredentialHelper || !workspaceCredentialUsername || !workspaceGitIdentity) {
@@ -5433,7 +5568,7 @@ async function startWorker(options, projectRuntime = {
5433
5568
  (project) => project.preserveOnlyBranches.map(({ branchId, branchName }) => ({ branchId, projectId: project.projectId, branchName }))
5434
5569
  );
5435
5570
  if (!incidentDeferral.incidentId) {
5436
- projectWorkspaceState = projectWorkspaceStateStore.reconcile({
5571
+ projectWorkspaceState = await projectWorkspaceStateStore.reconcile({
5437
5572
  desiredProjects: message.projects,
5438
5573
  preserveOnlyBranches
5439
5574
  });
@@ -5444,7 +5579,7 @@ async function startWorker(options, projectRuntime = {
5444
5579
  (candidate) => candidate.projectId === project.projectId && candidate.branchName === branch.branchName
5445
5580
  );
5446
5581
  if (pending2 && pending2.branchId === branch.branchId) {
5447
- projectWorkspaceState = projectWorkspaceStateStore.clearPendingCreatedBranch(pending2);
5582
+ projectWorkspaceState = await projectWorkspaceStateStore.clearPendingCreatedBranch(pending2);
5448
5583
  }
5449
5584
  }
5450
5585
  }
@@ -5612,7 +5747,7 @@ async function startWorker(options, projectRuntime = {
5612
5747
  for (const projectId of [...readyProjectIds]) {
5613
5748
  if (!projectConfigById.has(projectId)) readyProjectIds.delete(projectId);
5614
5749
  }
5615
- stageDurableWorkspaceDeletions();
5750
+ await stageDurableWorkspaceDeletions();
5616
5751
  const mountsBeforeGit = buildWorkspaceMounts();
5617
5752
  await hydrateWorkspaceGitMounts(
5618
5753
  workspaceShadowRoot,
@@ -5658,7 +5793,12 @@ async function startWorker(options, projectRuntime = {
5658
5793
  const connectSyncMs = Date.now() - connectSyncStartedAtMs;
5659
5794
  const result = syncResult.telemetry ? syncResult : {
5660
5795
  ...syncResult,
5661
- telemetry: { totalMs: connectSyncMs, queueMs: 0, prepareMs: lastWorkspaceSyncMirrorObservationMs, synchronizeMs: connectSyncMs }
5796
+ telemetry: {
5797
+ totalMs: connectSyncMs,
5798
+ queueMs: 0,
5799
+ prepareMs: lastWorkspaceSyncMirrorObservationMs,
5800
+ synchronizeMs: connectSyncMs
5801
+ }
5662
5802
  };
5663
5803
  assertConfigurationSyncStillAdmitted();
5664
5804
  if (receiptGeneration !== workspaceConfigurationReceiptGeneration) {
@@ -5677,7 +5817,7 @@ async function startWorker(options, projectRuntime = {
5677
5817
  ) ? [deletion.tombstoneId] : []
5678
5818
  );
5679
5819
  if (layoutCleanupIds.length > 0) {
5680
- projectWorkspaceState = projectWorkspaceStateStore.recordTreePublication({
5820
+ projectWorkspaceState = await projectWorkspaceStateStore.recordTreePublication({
5681
5821
  tombstoneIds: layoutCleanupIds,
5682
5822
  publishedHead
5683
5823
  });
@@ -5784,26 +5924,33 @@ async function startWorker(options, projectRuntime = {
5784
5924
  if (startupProjectSnapshotRecoveryCompleted) return;
5785
5925
  sendLifecycleProgress({ phase: "recovering", operationId, detail: "Checking interrupted workspace transactions" });
5786
5926
  const snapshotRecovery = await workspaceSyncSingleFlight.runExclusive(
5787
- async () => await recoverProjectSnapshotsInChild({
5788
- projectsRoot,
5789
- snapshotRoot: projectWorktreeSnapshotsRoot,
5790
- onProgress: (progress) => {
5791
- const lifecycleProgress = {
5792
- phase: "recovering",
5793
- operationId,
5794
- detail: describeSnapshotProgress(progress),
5795
- completedBytes: progress.completedBytes,
5796
- completedItems: progress.completedBranches,
5797
- totalItems: progress.totalBranches,
5798
- ...progress.totalBytes === void 0 ? {} : { totalBytes: progress.totalBytes }
5799
- };
5800
- sendLifecycleProgress(lifecycleProgress);
5801
- process.stdout.write(
5802
- `[r5d-worker] ${lifecycleProgress.detail}: ${progress.completedBranches}/${progress.totalBranches} branch(es), ${progress.completedBytes}/${progress.totalBytes ?? "?"} bytes
5927
+ async () => await workspaceFilesystemExecutor().run(
5928
+ "project_snapshots_recover",
5929
+ {
5930
+ projectsRoot,
5931
+ temporaryRoot: projectWorktreeSnapshotsRoot,
5932
+ currentProcessId: process.pid,
5933
+ currentOwnerSessionId: PROJECT_WORKTREE_SNAPSHOT_OWNER_SESSION_ID
5934
+ },
5935
+ {
5936
+ onProgress: (progress) => {
5937
+ const lifecycleProgress = {
5938
+ phase: "recovering",
5939
+ operationId,
5940
+ detail: describeSnapshotProgress(progress),
5941
+ completedBytes: progress.completedBytes,
5942
+ completedItems: progress.completedBranches,
5943
+ totalItems: progress.totalBranches,
5944
+ ...progress.totalBytes === void 0 ? {} : { totalBytes: progress.totalBytes }
5945
+ };
5946
+ sendLifecycleProgress(lifecycleProgress);
5947
+ process.stdout.write(
5948
+ `[r5d-worker] ${lifecycleProgress.detail}: ${progress.completedBranches}/${progress.totalBranches} branch(es), ${progress.completedBytes}/${progress.totalBytes ?? "?"} bytes
5803
5949
  `
5804
- );
5950
+ );
5951
+ }
5805
5952
  }
5806
- })
5953
+ )
5807
5954
  );
5808
5955
  if (snapshotRecovery.restored.length > 0) {
5809
5956
  process.stdout.write(`[r5d-worker] restored ${snapshotRecovery.restored.length} interrupted project snapshot(s)
@@ -5898,7 +6045,7 @@ async function startWorker(options, projectRuntime = {
5898
6045
  type: "hello",
5899
6046
  resumableProtocol: WORKER_RESUMABLE_PROTOCOL,
5900
6047
  runtimeId: workerRuntimeId,
5901
- ledgerId: recoveryStore.ledgerId,
6048
+ ledgerId: recoveryJournal.ledgerId,
5902
6049
  startupReady: startupRecoveryComplete,
5903
6050
  hostInfo: {
5904
6051
  hostname: hostname(),
@@ -5926,16 +6073,39 @@ async function startWorker(options, projectRuntime = {
5926
6073
  }
5927
6074
  };
5928
6075
  sendSerializedWorkerMessage(ws, JSON.stringify(hello));
6076
+ let outputPumpInFlight = false;
5929
6077
  outputReplayTimer = setInterval(() => {
5930
- if (workerRecoveryId !== void 0 || currentWorkerSocket !== ws) return;
5931
- for (const output of recoveryStore.output()) {
5932
- if (!sendReplayWorkerMessage(ws, output)) break;
5933
- }
6078
+ if (workerRecoveryId !== void 0 || currentWorkerSocket !== ws || outputPumpInFlight || !recoveryJournal) return;
6079
+ outputPumpInFlight = true;
6080
+ void (async () => {
6081
+ try {
6082
+ for await (const page of recoveryJournal.outputReplay(void 0)) {
6083
+ for (const output of page) {
6084
+ if (workerRecoveryId !== void 0 || currentWorkerSocket !== ws || !sendReplayWorkerMessage(ws, output)) return;
6085
+ }
6086
+ }
6087
+ } catch (error) {
6088
+ process.stderr.write(`[r5d-worker] output replay pump failed: ${error instanceof Error ? error.message : String(error)}
6089
+ `);
6090
+ } finally {
6091
+ outputPumpInFlight = false;
6092
+ }
6093
+ })();
5934
6094
  }, 250);
5935
6095
  outputReplayTimer.unref();
6096
+ let terminalPumpInFlight = false;
5936
6097
  terminalReplayTimer = setInterval(() => {
5937
- if (workerRecoveryId !== void 0) return;
5938
- for (const terminal of recoveryStore?.terminals() ?? []) sendReplayWorkerMessage(ws, terminal);
6098
+ if (workerRecoveryId !== void 0 || terminalPumpInFlight || !recoveryJournal) return;
6099
+ terminalPumpInFlight = true;
6100
+ void recoveryJournal.terminals().then((terminals) => {
6101
+ if (workerRecoveryId !== void 0 || currentWorkerSocket !== ws) return;
6102
+ for (const terminal of terminals) sendReplayWorkerMessage(ws, terminal);
6103
+ }).catch((error) => {
6104
+ process.stderr.write(`[r5d-worker] terminal replay pump failed: ${error instanceof Error ? error.message : String(error)}
6105
+ `);
6106
+ }).finally(() => {
6107
+ terminalPumpInFlight = false;
6108
+ });
5939
6109
  }, 5e3);
5940
6110
  terminalReplayTimer.unref();
5941
6111
  process.stdout.write("[r5d-worker] connected\n");
@@ -5948,28 +6118,37 @@ async function startWorker(options, projectRuntime = {
5948
6118
  if (message.type === "recovery_request") {
5949
6119
  workerRecoveryId = message.recoveryId;
5950
6120
  sendActiveProcessReport(ws);
5951
- sendWorkerMessage(ws, {
5952
- type: "recovery_state",
5953
- recoveryId: message.recoveryId,
5954
- runtimeId: workerRuntimeId,
5955
- ledgerId: recoveryStore.ledgerId,
5956
- operations: recoveryStore.operations()
5957
- });
5958
- const cursors = new Map(recoveryStore.outputCursors().map((cursor) => [cursor.runId, cursor]));
5959
- for (const cursor of message.outputOffsets) cursors.set(cursor.runId, cursor);
5960
- workerOutputWindow.reset([...cursors.values()]);
5961
- for (const response of recoveryStore.responses(message.requests)) sendReplayWorkerMessage(ws, response);
5962
- for (const output of recoveryStore.output(message.outputOffsets)) {
5963
- while (true) {
5964
- if (currentWorkerSocket !== ws || workerRecoveryId !== message.recoveryId || ws.readyState !== WebSocket.OPEN || workerLeaseExpired)
5965
- return;
5966
- if (sendReplayWorkerMessage(ws, output)) break;
5967
- await Bun.sleep(10);
6121
+ recoveryJournal.pauseDelivery();
6122
+ try {
6123
+ const state = await recoveryJournal.recoveryState(message.requests);
6124
+ if (currentWorkerSocket !== ws || workerRecoveryId !== message.recoveryId) return;
6125
+ sendWorkerMessage(ws, {
6126
+ type: "recovery_state",
6127
+ recoveryId: message.recoveryId,
6128
+ runtimeId: workerRuntimeId,
6129
+ ledgerId: recoveryJournal.ledgerId,
6130
+ operations: state.operations
6131
+ });
6132
+ const cursors = new Map(state.cursors.map((cursor) => [cursor.runId, cursor]));
6133
+ for (const cursor of message.outputOffsets) cursors.set(cursor.runId, cursor);
6134
+ workerOutputWindow.reset([...cursors.values()]);
6135
+ for (const response of state.responses) sendReplayWorkerMessage(ws, response);
6136
+ for await (const page of recoveryJournal.outputReplay(message.outputOffsets)) {
6137
+ for (const output of page) {
6138
+ while (true) {
6139
+ if (currentWorkerSocket !== ws || workerRecoveryId !== message.recoveryId || ws.readyState !== WebSocket.OPEN || workerLeaseExpired)
6140
+ return;
6141
+ if (sendReplayWorkerMessage(ws, output)) break;
6142
+ await Bun.sleep(10);
6143
+ }
6144
+ }
5968
6145
  }
6146
+ if (currentWorkerSocket !== ws || workerRecoveryId !== message.recoveryId) return;
6147
+ for (const terminal of state.terminals) sendReplayWorkerMessage(ws, terminal);
6148
+ sendWorkerMessage(ws, { type: "recovery_complete", recoveryId: message.recoveryId });
6149
+ } finally {
6150
+ recoveryJournal.resumeDelivery();
5969
6151
  }
5970
- if (currentWorkerSocket !== ws || workerRecoveryId !== message.recoveryId) return;
5971
- for (const terminal of recoveryStore.terminals()) sendReplayWorkerMessage(ws, terminal);
5972
- sendWorkerMessage(ws, { type: "recovery_complete", recoveryId: message.recoveryId });
5973
6152
  return;
5974
6153
  }
5975
6154
  if (message.type === "recovery_ready") {
@@ -5984,17 +6163,23 @@ async function startWorker(options, projectRuntime = {
5984
6163
  return;
5985
6164
  }
5986
6165
  if (message.type === "operation_result_ack") {
5987
- recoveryStore.acknowledgeResult(message.requestId);
6166
+ recoveryJournal.acknowledgeResult(message.requestId);
5988
6167
  return;
5989
6168
  }
5990
6169
  if (message.type === "exec_output_ack") {
5991
- recoveryStore.acknowledgeOutput(message.runId, message.stream, message.nextOffset);
6170
+ recoveryJournal.acknowledgeOutput(message.runId, message.stream, message.nextOffset);
5992
6171
  workerOutputWindow.acknowledge(message.runId, message.stream, message.nextOffset);
5993
6172
  return;
5994
6173
  }
5995
6174
  if (message.type === "cancel_session") {
5996
- recoveryStore.cancelSession(message.sessionId);
6175
+ const durableCancellation = recoveryJournal.cancelSession(message.sessionId);
6176
+ abortPendingReservations("Session cancelled before command start", message.sessionId);
5997
6177
  await Promise.all([
6178
+ durableCancellation,
6179
+ // File writes already in flight for the session settle before the
6180
+ // acknowledgement; queued ones re-check the cancellation before
6181
+ // their first write and never start.
6182
+ sessionFileMutations.settled(message.sessionId),
5998
6183
  workerCommandLauncher?.cancelSession(message.sessionId),
5999
6184
  ...[...activeProcesses].filter(([, active]) => active.sessionId === message.sessionId).map(async ([runId, active]) => {
6000
6185
  cancelledProcessRuns.add(runId);
@@ -6009,27 +6194,75 @@ async function startWorker(options, projectRuntime = {
6009
6194
  if (!startupRecoveryComplete && message.type !== "ping") return;
6010
6195
  if (currentWorkerSocket !== ws) return;
6011
6196
  const operationRequestId = "requestId" in message && typeof message.requestId === "string" && message.type !== "workspace_config" ? message.requestId : void 0;
6197
+ if (message.type === "cancel") {
6198
+ const admission = recoveryJournal.admit({ ...message, requestId: message.requestId });
6199
+ const cancelled = await cancelProcessRun(message.runId, message.scope);
6200
+ let admitted;
6201
+ try {
6202
+ admitted = await admission;
6203
+ } catch (error) {
6204
+ if (!(error instanceof WorkerRecoveryJournalBusyError)) throw error;
6205
+ process.stderr.write(`[r5d-worker] cancel ${message.runId} stopped the run but was not durably admitted: ${error.message}
6206
+ `);
6207
+ return;
6208
+ }
6209
+ if (admitted.admission === "conflict") {
6210
+ ws.close(1008, "Operation identity conflict");
6211
+ return;
6212
+ }
6213
+ sendWorkerMessage(ws, { type: "operation_received", requestId: message.requestId });
6214
+ if (admitted.admission !== "new") {
6215
+ if (admitted.response && admitted.admission !== "unknown") sendReplayWorkerMessage(ws, admitted.response);
6216
+ return;
6217
+ }
6218
+ sendSerializedWorkerMessage(
6219
+ ws,
6220
+ JSON.stringify({
6221
+ type: "cancel_result",
6222
+ requestId: message.requestId,
6223
+ runId: message.runId,
6224
+ cancelled: cancelled.cancelled,
6225
+ message: cancelled.message,
6226
+ outcome: cancelled.outcome
6227
+ })
6228
+ );
6229
+ return;
6230
+ }
6012
6231
  if (operationRequestId) {
6013
- const admission = recoveryStore.admit({ ...message, requestId: operationRequestId });
6014
- if (admission === "conflict") {
6232
+ let admitted;
6233
+ try {
6234
+ admitted = await recoveryJournal.admit({ ...message, requestId: operationRequestId });
6235
+ } catch (error) {
6236
+ if (!(error instanceof WorkerRecoveryJournalBusyError)) throw error;
6237
+ process.stderr.write(`[r5d-worker] ${message.type} ${operationRequestId} was not admitted: ${error.message}
6238
+ `);
6239
+ return;
6240
+ }
6241
+ if (admitted.admission === "conflict") {
6015
6242
  ws.close(1008, "Operation identity conflict");
6016
6243
  return;
6017
6244
  }
6018
6245
  sendWorkerMessage(ws, { type: "operation_received", requestId: operationRequestId });
6019
- if (admission !== "new") {
6020
- const response = recoveryStore.response(operationRequestId);
6021
- if (response && admission !== "unknown") sendReplayWorkerMessage(ws, response);
6022
- if (!(admission === "unknown" && message.type === "delete_project_branch" && recoveryStore.readmitUnknownBranchDeletion(operationRequestId))) {
6246
+ if (admitted.admission !== "new") {
6247
+ if (admitted.response && admitted.admission !== "unknown") sendReplayWorkerMessage(ws, admitted.response);
6248
+ if (!(admitted.admission === "unknown" && message.type === "delete_project_branch" && await recoveryJournal.readmitUnknownBranchDeletion(
6249
+ operationRequestId,
6250
+ "sessionId" in message && typeof message.sessionId === "string" ? message.sessionId : void 0
6251
+ ))) {
6023
6252
  return;
6024
6253
  }
6025
6254
  }
6255
+ if (recoveryJournal.isRequestCancelled(operationRequestId)) {
6256
+ recoveryJournal.unknown(operationRequestId);
6257
+ return;
6258
+ }
6026
6259
  }
6027
6260
  const messageAdmissionGeneration = workerAdmissionGeneration;
6028
6261
  const admissionCredentials = configuredCredentialGenerationFingerprint;
6029
6262
  const assertMessageAdmission = () => {
6030
6263
  if (operationRequestId) {
6031
- if (workerLeaseExpired || recoveryStore.isRequestCancelled(operationRequestId) || admissionCredentials !== configuredCredentialGenerationFingerprint || shutdownAfterClose || !workspaceConfigured) {
6032
- recoveryStore.unknown(operationRequestId);
6264
+ if (workerLeaseExpired || recoveryJournal.isRequestCancelled(operationRequestId) || admissionCredentials !== configuredCredentialGenerationFingerprint || shutdownAfterClose || !workspaceConfigured) {
6265
+ recoveryJournal.unknown(operationRequestId);
6033
6266
  throw new StaleWorkerAdmissionError();
6034
6267
  }
6035
6268
  return;
@@ -6166,7 +6399,7 @@ async function startWorker(options, projectRuntime = {
6166
6399
  }
6167
6400
  recoveryDeadline ??= Date.now() + WORKER_RECOVERY_TIMEOUT_MS;
6168
6401
  if (workerLeaseExpired || Date.now() >= recoveryDeadline) {
6169
- recoveryStore.unknown(message.requestId);
6402
+ recoveryJournal.unknown(message.requestId);
6170
6403
  return;
6171
6404
  }
6172
6405
  currentWorkerSocket?.close(1012, "Communication recovery");
@@ -6196,7 +6429,7 @@ async function startWorker(options, projectRuntime = {
6196
6429
  projectId: message.projectId,
6197
6430
  branchName: message.targetBranch
6198
6431
  };
6199
- const created = await workspaceSyncSingleFlight.runBranchMutation(() => {
6432
+ const created = await workspaceSyncSingleFlight.runBranchMutation(async () => {
6200
6433
  if (workspaceOperationsAreFenced(deferredWorkspaceConfiguration, activeWorkspaceIncidentId)) {
6201
6434
  throw new Error(
6202
6435
  `Project branch creation was fenced while queued by workspace incident ${deferredWorkspaceConfiguration?.incidentId ?? activeWorkspaceIncidentId}`
@@ -6211,7 +6444,7 @@ async function startWorker(options, projectRuntime = {
6211
6444
  const pendingWasRecorded = projectWorkspaceState.locallyPendingCreatedBranches.some(
6212
6445
  ({ projectId, branchName }) => projectId === message.projectId && branchName === message.targetBranch
6213
6446
  );
6214
- projectWorkspaceState = projectWorkspaceStateStore.recordPendingCreatedBranch(pendingBranch);
6447
+ projectWorkspaceState = await projectWorkspaceStateStore.recordPendingCreatedBranch(pendingBranch);
6215
6448
  let gitBranchCreated = false;
6216
6449
  try {
6217
6450
  if (!readyProjectIds.has(project.projectId)) {
@@ -6232,7 +6465,7 @@ async function startWorker(options, projectRuntime = {
6232
6465
  }
6233
6466
  return { branchPath: existingPath, baseCommitHash: existingBranch.baseCommitHash };
6234
6467
  }
6235
- const createdBranch = createOrRetryLinkedProjectBranch({
6468
+ const createdBranch = await createOrRetryLinkedProjectBranch({
6236
6469
  projectRoot: configuredProjectRoot(projectsRoot, project),
6237
6470
  primaryBranchName: primaryProjectBranch(project),
6238
6471
  sourceBranchName: message.sourceBranch,
@@ -6257,7 +6490,7 @@ async function startWorker(options, projectRuntime = {
6257
6490
  return createdBranch;
6258
6491
  } catch (error) {
6259
6492
  if (!pendingWasRecorded && !gitBranchCreated && !projectBranchMayExistAfterCreateFailure(error)) {
6260
- projectWorkspaceState = projectWorkspaceStateStore.rollbackPendingCreatedBranch(pendingBranch);
6493
+ projectWorkspaceState = await projectWorkspaceStateStore.rollbackPendingCreatedBranch(pendingBranch);
6261
6494
  }
6262
6495
  throw error;
6263
6496
  }
@@ -6301,7 +6534,7 @@ async function startWorker(options, projectRuntime = {
6301
6534
  });
6302
6535
  try {
6303
6536
  const deletionKey = projectBranchKey(message.projectId, message.branchName);
6304
- const deletionNeedsSync = await workspaceSyncSingleFlight.runBranchMutation(() => {
6537
+ const deletionNeedsSync = await workspaceSyncSingleFlight.runBranchMutation(async () => {
6305
6538
  if (workspaceOperationsAreFenced(deferredWorkspaceConfiguration, activeWorkspaceIncidentId)) {
6306
6539
  throw new Error(
6307
6540
  `Project branch deletion was fenced while queued by workspace incident ${deferredWorkspaceConfiguration?.incidentId ?? activeWorkspaceIncidentId}`
@@ -6328,12 +6561,12 @@ async function startWorker(options, projectRuntime = {
6328
6561
  if (!configuredBranch && !pendingLocalBranch && !hasDurableTombstone && !pendingMirrorDeletes.has(deletionKey)) {
6329
6562
  return false;
6330
6563
  }
6331
- projectWorkspaceState = projectWorkspaceStateStore.beginBranchDeletion({
6564
+ projectWorkspaceState = await projectWorkspaceStateStore.beginBranchDeletion({
6332
6565
  projectId: message.projectId,
6333
6566
  branchName: message.branchName
6334
6567
  });
6335
6568
  if (pendingLocalBranch) {
6336
- projectWorkspaceState = projectWorkspaceStateStore.clearPendingCreatedBranch(pendingLocalBranch);
6569
+ projectWorkspaceState = await projectWorkspaceStateStore.clearPendingCreatedBranch(pendingLocalBranch);
6337
6570
  }
6338
6571
  pendingCreatedBranchPublicationNotBefore.delete(pendingCreatedBranchKey(message.projectId, message.branchName));
6339
6572
  if (pendingMirrorDeletes.has(deletionKey)) return true;
@@ -6437,7 +6670,7 @@ async function startWorker(options, projectRuntime = {
6437
6670
  }
6438
6671
  if (message.type === "exec_terminal_ack") {
6439
6672
  pendingProcessTerminals.delete(message.runId);
6440
- recoveryStore.acknowledgeTerminal(message.runId);
6673
+ recoveryJournal.acknowledgeTerminal(message.runId);
6441
6674
  return;
6442
6675
  }
6443
6676
  if (message.type === "port_forward_connect") {
@@ -6492,42 +6725,6 @@ async function startWorker(options, projectRuntime = {
6492
6725
  sendSerializedWorkerMessage(ws, JSON.stringify({ type: "pong" }));
6493
6726
  return;
6494
6727
  }
6495
- if (message.type === "cancel") {
6496
- let resourceCancellationError;
6497
- try {
6498
- await workerCommandLauncher?.cancel(message.runId);
6499
- } catch (error) {
6500
- resourceCancellationError = error instanceof Error ? error.message : String(error);
6501
- }
6502
- const active = activeProcesses.get(message.runId);
6503
- let cancelled = resourceCancellationError === void 0;
6504
- let cancelMessage;
6505
- if (active) {
6506
- try {
6507
- closeProcessStdin(active);
6508
- await terminateProcessTree(active.process);
6509
- cancelMessage = `Stopped ${active.command}`;
6510
- } catch (error) {
6511
- cancelled = false;
6512
- cancelMessage = `Failed to stop ${active.command}: ${error instanceof Error ? error.message : String(error)}`;
6513
- }
6514
- } else {
6515
- cancelledProcessRuns.add(message.runId);
6516
- cancelMessage = "Cancellation queued before command start";
6517
- }
6518
- if (resourceCancellationError) cancelMessage += `; job resource termination failed: ${resourceCancellationError}`;
6519
- sendSerializedWorkerMessage(
6520
- ws,
6521
- JSON.stringify({
6522
- type: "cancel_result",
6523
- requestId: message.requestId,
6524
- runId: message.runId,
6525
- cancelled,
6526
- message: cancelMessage
6527
- })
6528
- );
6529
- return;
6530
- }
6531
6728
  if (message.type === "exec_stdin") {
6532
6729
  const sendAck = (payload) => sendSerializedWorkerMessage(
6533
6730
  ws,
@@ -6608,10 +6805,20 @@ async function startWorker(options, projectRuntime = {
6608
6805
  let mutationLeaseTransferred = false;
6609
6806
  try {
6610
6807
  resources = await acquireWorkerCommandLaunch(ws, message, assertMessageAdmission);
6611
- await reserveWorkspaceCommandAfterCurrentSync(message.target, workspaceSyncSingleFlight, () => {
6612
- workspaceSyncPriorityPtyTargets.set(message.ptyId, message.target);
6613
- if (targetMayMutateVisibleWorkspace(message.target)) recordVisibleWorkspaceMutation(message.target);
6614
- });
6808
+ const reservationAbort = registerPendingReservation(`pty:${message.ptyId}`, void 0);
6809
+ try {
6810
+ await reserveWorkspaceCommandAfterCurrentSync(
6811
+ message.target,
6812
+ workspaceSyncSingleFlight,
6813
+ () => {
6814
+ workspaceSyncPriorityPtyTargets.set(message.ptyId, message.target);
6815
+ if (targetMayMutateVisibleWorkspace(message.target)) recordVisibleWorkspaceMutation(message.target);
6816
+ },
6817
+ { signal: reservationAbort.signal }
6818
+ );
6819
+ } finally {
6820
+ unregisterPendingReservation(`pty:${message.ptyId}`, reservationAbort);
6821
+ }
6615
6822
  releaseWorkspaceMutation = await acquireWorkspaceCommandMutation(message.target, workspaceSyncSingleFlight);
6616
6823
  if (workspaceOperationsAreFenced(deferredWorkspaceConfiguration, activeWorkspaceIncidentId)) {
6617
6824
  throw new Error(
@@ -6676,6 +6883,7 @@ async function startWorker(options, projectRuntime = {
6676
6883
  }
6677
6884
  if (message.type === "pty_close") {
6678
6885
  const activePty = activePtys.get(message.ptyId);
6886
+ abortPendingReservation(`pty:${message.ptyId}`, "Shell closed before it started");
6679
6887
  await closePty(message);
6680
6888
  if (activePty && targetMayMutateVisibleWorkspace(activePty.target)) {
6681
6889
  markWorkspaceDirty({ type: "process_terminal", detail: `pty ${message.ptyId} closed` }, true, activePty.target);
@@ -6712,11 +6920,21 @@ async function startWorker(options, projectRuntime = {
6712
6920
  try {
6713
6921
  resources = await acquireWorkerCommandLaunch(ws, message, assertMessageAdmission);
6714
6922
  if (hasWorkspaceEffect) {
6715
- await reserveWorkspaceCommandAfterCurrentSync(message.target, workspaceSyncSingleFlight, () => {
6716
- workspaceSyncPriorityProcessTargets.set(message.runId, message.target);
6717
- targetReserved = true;
6718
- if (targetMayMutateVisibleWorkspace(message.target)) recordVisibleWorkspaceMutation(message.target);
6719
- });
6923
+ const reservationAbort = registerPendingReservation(`run:${message.runId}`, message.sessionId);
6924
+ try {
6925
+ await reserveWorkspaceCommandAfterCurrentSync(
6926
+ message.target,
6927
+ workspaceSyncSingleFlight,
6928
+ () => {
6929
+ workspaceSyncPriorityProcessTargets.set(message.runId, message.target);
6930
+ targetReserved = true;
6931
+ if (targetMayMutateVisibleWorkspace(message.target)) recordVisibleWorkspaceMutation(message.target);
6932
+ },
6933
+ { signal: reservationAbort.signal }
6934
+ );
6935
+ } finally {
6936
+ unregisterPendingReservation(`run:${message.runId}`, reservationAbort);
6937
+ }
6720
6938
  }
6721
6939
  const runCommand = async () => {
6722
6940
  if (!workspaceCommandTransportIsAllowed(deferredWorkspaceConfiguration, activeWorkspaceIncidentId, "exec")) {
@@ -6768,7 +6986,7 @@ async function startWorker(options, projectRuntime = {
6768
6986
  cancelledProcessRuns.delete(message.runId);
6769
6987
  }
6770
6988
  if (infrastructureStoppedRunIds.has(message.runId)) {
6771
- recoveryStore.unknown(message.requestId);
6989
+ recoveryJournal.unknown(message.requestId);
6772
6990
  return;
6773
6991
  }
6774
6992
  sendSerializedWorkerMessage(ws, JSON.stringify(result));
@@ -6842,11 +7060,21 @@ async function startWorker(options, projectRuntime = {
6842
7060
  try {
6843
7061
  resources = await acquireWorkerCommandLaunch(ws, message, assertMessageAdmission);
6844
7062
  if (hasWorkspaceEffect) {
6845
- await reserveWorkspaceCommandAfterCurrentSync(message.target, workspaceSyncSingleFlight, () => {
6846
- workspaceSyncPriorityProcessTargets.set(message.runId, message.target);
6847
- targetReserved = true;
6848
- if (targetMayMutateVisibleWorkspace(message.target)) recordVisibleWorkspaceMutation(message.target);
6849
- });
7063
+ const reservationAbort = registerPendingReservation(`run:${message.runId}`, message.sessionId);
7064
+ try {
7065
+ await reserveWorkspaceCommandAfterCurrentSync(
7066
+ message.target,
7067
+ workspaceSyncSingleFlight,
7068
+ () => {
7069
+ workspaceSyncPriorityProcessTargets.set(message.runId, message.target);
7070
+ targetReserved = true;
7071
+ if (targetMayMutateVisibleWorkspace(message.target)) recordVisibleWorkspaceMutation(message.target);
7072
+ },
7073
+ { signal: reservationAbort.signal }
7074
+ );
7075
+ } finally {
7076
+ unregisterPendingReservation(`run:${message.runId}`, reservationAbort);
7077
+ }
6850
7078
  }
6851
7079
  await runReservedWorkspaceCommand(message.target, workspaceSyncSingleFlight, runCommand, () => {
6852
7080
  if (targetReserved) {
@@ -6881,16 +7109,40 @@ async function startWorker(options, projectRuntime = {
6881
7109
  return;
6882
7110
  }
6883
7111
  const reservesVisibleWorkspace = targetMayMutateVisibleWorkspace(message.target);
6884
- const mutatesVisibleWorkspace = (message.type === "write" || message.type === "edit" || message.type === "update_task_status" || message.type === "read_plan" && message.createIfMissing) && targetMayMutateVisibleWorkspace(message.target);
7112
+ const mutatesFiles = message.type === "write" || message.type === "edit" || message.type === "update_task_status" || message.type === "read_plan" && message.createIfMissing;
7113
+ const mutatesVisibleWorkspace = mutatesFiles && targetMayMutateVisibleWorkspace(message.target);
6885
7114
  if (reservesVisibleWorkspace) {
6886
- await reserveWorkspaceCommandAfterCurrentSync(message.target, workspaceSyncSingleFlight, () => {
6887
- workspaceSyncPriorityOperationTargets.set(message.requestId, message.target);
6888
- if (mutatesVisibleWorkspace) recordVisibleWorkspaceMutation(message.target);
6889
- });
7115
+ const reservationAbort = registerPendingReservation(
7116
+ `op:${message.requestId}`,
7117
+ "sessionId" in message && typeof message.sessionId === "string" ? message.sessionId : void 0
7118
+ );
7119
+ try {
7120
+ await reserveWorkspaceCommandAfterCurrentSync(
7121
+ message.target,
7122
+ workspaceSyncSingleFlight,
7123
+ () => {
7124
+ workspaceSyncPriorityOperationTargets.set(message.requestId, message.target);
7125
+ if (mutatesVisibleWorkspace) recordVisibleWorkspaceMutation(message.target);
7126
+ },
7127
+ { signal: reservationAbort.signal }
7128
+ );
7129
+ } catch (error) {
7130
+ sendSerializedWorkerMessage(
7131
+ ws,
7132
+ JSON.stringify({
7133
+ type: "operation_result",
7134
+ requestId: message.requestId,
7135
+ error: error instanceof Error ? error.message : String(error)
7136
+ })
7137
+ );
7138
+ return;
7139
+ } finally {
7140
+ unregisterPendingReservation(`op:${message.requestId}`, reservationAbort);
7141
+ }
6890
7142
  }
6891
7143
  let dirtyTrigger;
6892
7144
  try {
6893
- const result = await runWorkspaceCommand(message.target, workspaceSyncSingleFlight, async () => {
7145
+ const operation = runWorkspaceCommand(message.target, workspaceSyncSingleFlight, async () => {
6894
7146
  const resolvedTarget = resolveMessageTarget(message.target);
6895
7147
  return await executeOperation({
6896
7148
  message,
@@ -6901,6 +7153,8 @@ async function startWorker(options, projectRuntime = {
6901
7153
  assertAdmission: assertMessageAdmission
6902
7154
  });
6903
7155
  });
7156
+ if (mutatesFiles && typeof message.sessionId === "string") sessionFileMutations.track(message.sessionId, operation);
7157
+ const result = await operation;
6904
7158
  sendSerializedWorkerMessage(
6905
7159
  ws,
6906
7160
  JSON.stringify({
@@ -6984,6 +7238,7 @@ async function startWorker(options, projectRuntime = {
6984
7238
  projectRuntime.configuration.deferred = deferredWorkspaceConfiguration;
6985
7239
  }
6986
7240
  if (currentWorkerSocket === ws) currentWorkerSocket = null;
7241
+ abortPendingReservations("Worker connection closed before command start");
6987
7242
  workerRecoveryId = "reconnecting";
6988
7243
  const reason = event.reason ? `: ${event.reason}` : "";
6989
7244
  process.stderr.write(`[r5d-worker] disconnected (${event.code}${reason})
@@ -7017,6 +7272,11 @@ async function startWorker(options, projectRuntime = {
7017
7272
  });
7018
7273
  }
7019
7274
  function installWorkerRuntimeFailureLogging() {
7275
+ installWorkspaceFilesystemExecutorFailureHandler((error) => {
7276
+ process.stderr.write(`[r5d-worker] ${error.message}; restarting the worker runtime
7277
+ `);
7278
+ void exitWorkerRuntime(WORKER_RECONNECT_EXIT_CODE);
7279
+ });
7020
7280
  process.on("uncaughtException", (error) => {
7021
7281
  process.stderr.write(`[r5d-worker] uncaught exception: ${error instanceof Error ? error.stack ?? error.message : String(error)}
7022
7282
  `);
@@ -7029,11 +7289,6 @@ function installWorkerRuntimeFailureLogging() {
7029
7289
  });
7030
7290
  }
7031
7291
  async function main() {
7032
- if (process.argv[2] === PROJECT_SNAPSHOT_RECOVERY_HELPER_COMMAND) {
7033
- const exitCode2 = runProjectSnapshotRecoveryHelper(process.argv.slice(3));
7034
- if (exitCode2 !== 0) process.exit(exitCode2);
7035
- return;
7036
- }
7037
7292
  const parsed = parseArgs(process.argv.slice(2));
7038
7293
  if (parsed.command === "help") {
7039
7294
  printHelp();