@ricsam/r5d-worker 0.0.101 → 0.0.103

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
@@ -34,6 +34,8 @@ import {
34
34
  runWorkspaceCommand
35
35
  } from "./workspace-command-sync-policy.mjs";
36
36
  import { WorkspaceMutationGate } from "./workspace-mutation-gate.mjs";
37
+ import { WorkspaceProjectionLedger } from "./workspace-projection-ledger.mjs";
38
+ import { WorkspaceSyncCoalescer } from "./workspace-sync-coalescer.mjs";
37
39
  import { checkoutPathMovePublicationComplete, preserveProjectCheckoutPathMove } from "./workspace-path-move.mjs";
38
40
  import { busyProjectConfigurationChangeIds, deferredProjectConfigurationPendingBranches } from "./workspace-project-config-policy.mjs";
39
41
  import { creatorLocalProjectBranchIsAuthorized, workerProjectBranchDisposition } from "./workspace-preserve-only-policy.mjs";
@@ -58,7 +60,7 @@ import { assertDisjointManagedRoots, managedProjectRoot, validateManagedBranchNa
58
60
  import {
59
61
  applyObservedProjectMirrorHeads,
60
62
  assertProjectMirrorHeadsPushed,
61
- computeLocalProjectMirrorRefsToken,
63
+ computeLocalProjectMirrorRefsTokens,
62
64
  createOrRetryLinkedProjectBranch,
63
65
  deleteLinkedProjectBranch,
64
66
  deleteProjectMirrorBranch,
@@ -71,7 +73,10 @@ import {
71
73
  pushProjectMirrorHeads,
72
74
  removeProjectWorktrees
73
75
  } from "./project-worktrees.mjs";
74
- import { projectMirrorFetchDecision } from "./project-mirror-fetch-policy.mjs";
76
+ import {
77
+ parseProjectMirrorRefsTokensMessage,
78
+ planProjectMirrorObservationFetch
79
+ } from "./project-mirror-fetch-policy.mjs";
75
80
  import {
76
81
  PROJECT_SNAPSHOT_RECOVERY_HELPER_COMMAND,
77
82
  recoverProjectSnapshotsInChild,
@@ -182,6 +187,12 @@ const workspaceSyncSingleFlight = {
182
187
  acquireMutation() {
183
188
  return workspaceMutationGate.acquireMutation();
184
189
  },
190
+ runBranchMutation(operation) {
191
+ return workspaceMutationGate.runBranchMutation(operation);
192
+ },
193
+ branchMutationQueuePosition() {
194
+ return workspaceMutationGate.branchMutationQueuePosition();
195
+ },
185
196
  afterCurrent() {
186
197
  return workspaceSyncQueue;
187
198
  }
@@ -312,6 +323,7 @@ const workerPtyTestHarness = {
312
323
  removeEnvFiles: removePtyEnvFiles,
313
324
  resolveEnvFileReferences: resolvePtyEnvFileReferences,
314
325
  commandHasWorkspaceEffect: workerCommandHasWorkspaceEffect,
326
+ targetProcessEnv,
315
327
  canonicalSyncTerminalHead,
316
328
  ptyIsWorkspaceBusy: workerPtyIsWorkspaceBusy,
317
329
  parseLinuxForegroundBusy: parseLinuxPtyForegroundBusy
@@ -319,21 +331,12 @@ const workerPtyTestHarness = {
319
331
  function defaultConfigPath() {
320
332
  return path.join(os.homedir(), ".config", "r5d", "r5dctl", "config.json");
321
333
  }
322
- function defaultProjectsRoot() {
323
- return path.join(os.homedir(), ".r5d", "projects");
324
- }
325
- function defaultSyncRoot() {
326
- return path.join(os.homedir(), ".r5d", "sync");
327
- }
328
- function defaultArtifactRoot() {
329
- return path.join(os.homedir(), ".r5d", "artifacts");
330
- }
331
- function defaultPlanRoot() {
332
- return path.join(os.homedir(), ".r5d", "plans");
334
+ function defaultRootDir() {
335
+ return path.join(os.homedir(), ".r5d");
333
336
  }
334
337
  function printHelp() {
335
338
  process.stdout.write(`Usage:
336
- r5d-worker start --label <label> [--base-url <url>] [--token <token>] [--api-key <key>]
339
+ r5d-worker start --label <label> [--root <dir>] [--base-url <url>] [--token <token>] [--api-key <key>]
337
340
  r5d-worker --version
338
341
 
339
342
  Options:
@@ -342,10 +345,7 @@ Options:
342
345
  --token <token> r5d worker token
343
346
  --api-key <key> r5d API key
344
347
  --config <path> Config path (default ~/.config/r5d/r5dctl/config.json)
345
- --project-root <dir> Override projects checkout root (default ~/.r5d/projects)
346
- --sync-root <dir> Override r5d internal sync git root (default ~/.r5d/sync)
347
- --artifact-root <dir> Override session artifact root (default from R5D_ARTIFACTS_ROOT or ~/.r5d/artifacts)
348
- --plan-root <dir> Override synced plan root (default from R5D_PLANS_ROOT or ~/.r5d/plans)
348
+ --root <dir> Override worker data root (default from R5D_ROOT or ~/.r5d)
349
349
  -v, --version Show r5d-worker version
350
350
  -h, --help Show this help
351
351
  `);
@@ -460,24 +460,9 @@ function parseArgs(argv) {
460
460
  options.label = label;
461
461
  continue;
462
462
  }
463
- const projectRoot = readOption("--project-root");
464
- if (projectRoot !== void 0) {
465
- options.projectRoot = projectRoot;
466
- continue;
467
- }
468
- const syncRoot = readOption("--sync-root");
469
- if (syncRoot !== void 0) {
470
- options.syncRoot = syncRoot;
471
- continue;
472
- }
473
- const artifactRoot = readOption("--artifact-root");
474
- if (artifactRoot !== void 0) {
475
- options.artifactRoot = artifactRoot;
476
- continue;
477
- }
478
- const planRoot = readOption("--plan-root");
479
- if (planRoot !== void 0) {
480
- options.planRoot = planRoot;
463
+ const rootDir = readOption("--root");
464
+ if (rootDir !== void 0) {
465
+ options.rootDir = rootDir;
481
466
  continue;
482
467
  }
483
468
  rest.push(arg);
@@ -522,11 +507,9 @@ async function readResponseText(response) {
522
507
  return "";
523
508
  }
524
509
  }
525
- const R5D_ARTIFACTS_DIR_REF = "$R5D_ARTIFACTS_DIR";
526
- const R5D_PLANS_DIR_REF = "$R5D_PLANS_DIR";
527
- const R5D_ACTIVE_PLAN_FILE_REF = "$R5D_ACTIVE_PLAN_FILE";
528
- const R5D_PLANS_DIR_ENV = "R5D_PLANS_DIR";
529
- const BUILT_IN_TOOL_PATH_REFS = [R5D_ARTIFACTS_DIR_REF, R5D_PLANS_DIR_REF, R5D_ACTIVE_PLAN_FILE_REF];
510
+ const R5D_ROOT_REF = "$R5D_ROOT";
511
+ const R5D_ROOT_ENV = "R5D_ROOT";
512
+ const BUILT_IN_TOOL_PATH_REFS = [R5D_ROOT_REF];
530
513
  function parseBuiltInToolPath(inputPath) {
531
514
  const normalized = inputPath.replace(/\\/g, "/");
532
515
  const match = /^\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))(?:\/(.*))?$/.exec(normalized);
@@ -542,6 +525,26 @@ function parseBuiltInToolPath(inputPath) {
542
525
  relativePath: match[3] ?? ""
543
526
  };
544
527
  }
528
+ function expandRootPathSegments(relativePath, builtInPaths) {
529
+ const values = {
530
+ R5D_SESSION_ID: builtInPaths.sessionId,
531
+ R5D_PROJECT_ID: builtInPaths.projectId,
532
+ R5D_BRANCH_NAME: builtInPaths.branchName,
533
+ R5D_ACTIVE_PLAN_ID: builtInPaths.activePlanId
534
+ };
535
+ let expandedPath = relativePath;
536
+ for (const [variableName, value] of Object.entries(values)) {
537
+ const variablePattern = new RegExp(`\\$(?:\\{${variableName}\\}|${variableName})(?=\\/|\\.|$)`, "g");
538
+ if (!variablePattern.test(expandedPath)) continue;
539
+ if (!value) throw new Error(`$${variableName} is unavailable for this worker path`);
540
+ expandedPath = expandedPath.replace(variablePattern, value);
541
+ }
542
+ const unsupportedVariable = /\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))/.exec(expandedPath);
543
+ if (unsupportedVariable) {
544
+ throw new Error(`Unsupported ${R5D_ROOT_REF} path variable "$${unsupportedVariable[1] ?? unsupportedVariable[2]}"`);
545
+ }
546
+ return expandedPath.split("/").filter(Boolean);
547
+ }
545
548
  function validateArtifactSessionId(sessionId) {
546
549
  if (!/^(migration-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(sessionId)) {
547
550
  throw new Error(`Invalid artifact session id: ${sessionId}`);
@@ -557,14 +560,6 @@ function sessionArtifactDir(artifactRoot, sessionId) {
557
560
  validateArtifactSessionId(sessionId);
558
561
  return path.join(artifactRoot, sessionId);
559
562
  }
560
- function artifactEnv(artifactRoot, sessionId) {
561
- if (!sessionId) {
562
- return {};
563
- }
564
- return {
565
- R5D_ARTIFACTS_DIR: sessionArtifactDir(artifactRoot, sessionId)
566
- };
567
- }
568
563
  function projectPlanDir(planRoot, projectId, branchName) {
569
564
  validateProjectId(projectId);
570
565
  validateBranchName(branchName);
@@ -577,21 +572,18 @@ function targetPlanDir(planRoot, target) {
577
572
  }
578
573
  return path.join(planRoot, "workspace");
579
574
  }
580
- function planEnv(planRoot, target, activePlanId) {
581
- const plansDir = targetPlanDir(planRoot, target);
582
- const env = {
583
- [R5D_PLANS_DIR_ENV]: plansDir
584
- };
575
+ function planEnv(activePlanId) {
576
+ const env = {};
585
577
  if (activePlanId) {
586
578
  validatePlanId(activePlanId);
587
579
  env.R5D_ACTIVE_PLAN_ID = activePlanId;
588
- env.R5D_ACTIVE_PLAN_FILE = path.join(plansDir, `${activePlanId}.plan.md`);
589
580
  }
590
581
  return env;
591
582
  }
592
583
  function isArtifactEnvPath(filePath) {
593
584
  try {
594
- return parseBuiltInToolPath(filePath)?.ref === R5D_ARTIFACTS_DIR_REF;
585
+ const parsed = parseBuiltInToolPath(filePath);
586
+ return parsed?.ref === R5D_ROOT_REF && parsed.relativePath.split("/").filter(Boolean)[0] === "artifacts";
595
587
  } catch {
596
588
  return false;
597
589
  }
@@ -733,48 +725,46 @@ async function prepareBuiltInToolPaths(input) {
733
725
  if (!parsed) {
734
726
  return void 0;
735
727
  }
736
- if (parsed.ref === R5D_ARTIFACTS_DIR_REF) {
728
+ const builtInPaths = {
729
+ rootDir: input.rootDir,
730
+ sessionId: input.sessionId,
731
+ activePlanId: input.activePlanId,
732
+ ...input.target.type === "project" ? { projectId: input.target.projectId, branchName: input.target.branchName } : {}
733
+ };
734
+ const rootPathSegments = expandRootPathSegments(parsed.relativePath, builtInPaths);
735
+ if (rootPathSegments[0] === "artifacts") {
737
736
  if (input.access === "write") {
738
737
  throw new Error(
739
- `${R5D_ARTIFACTS_DIR_REF} attachments are read-only. Copy them into a project path before editing or creating files.`
738
+ `${R5D_ROOT_REF}/artifacts attachments are read-only. Copy them into a project path before editing or creating files.`
740
739
  );
741
740
  }
742
- if (!input.sessionId) {
743
- throw new Error(`${R5D_ARTIFACTS_DIR_REF} paths require an active chat session`);
744
- }
745
- return {
746
- artifactsDir: await syncSessionArtifacts({
747
- baseUrl: input.baseUrl,
748
- token: input.token,
749
- sessionId: input.sessionId,
750
- artifactRoot: input.artifactRoot
751
- })
752
- };
753
- }
754
- const plansDir = targetPlanDir(input.planRoot, input.target);
755
- fs.mkdirSync(plansDir, { recursive: true });
756
- if (parsed.ref === R5D_PLANS_DIR_REF) {
757
- return { plansDir };
741
+ const referencedSessionId = rootPathSegments[1];
742
+ if (!referencedSessionId) return builtInPaths;
743
+ validateArtifactSessionId(referencedSessionId);
744
+ await syncSessionArtifacts({
745
+ baseUrl: input.baseUrl,
746
+ token: input.token,
747
+ sessionId: referencedSessionId,
748
+ artifactRoot: path.join(input.rootDir, "artifacts")
749
+ });
758
750
  }
759
- if (!input.activePlanId) {
760
- throw new Error(`${R5D_ACTIVE_PLAN_FILE_REF} paths require an active plan`);
751
+ if (rootPathSegments[0] === "plans") {
752
+ fs.mkdirSync(targetPlanDir(path.join(input.rootDir, "plans"), input.target), { recursive: true });
761
753
  }
762
- validatePlanId(input.activePlanId);
763
- return {
764
- plansDir,
765
- activePlanFile: path.join(plansDir, `${input.activePlanId}.plan.md`)
766
- };
754
+ return builtInPaths;
767
755
  }
768
756
  async function prepareArtifactEnvForShell(input) {
757
+ const env = { [R5D_ROOT_ENV]: input.rootDir };
769
758
  if (!input.sessionId) {
770
- return {};
759
+ return env;
771
760
  }
761
+ const artifactRoot = path.join(input.rootDir, "artifacts");
772
762
  try {
773
763
  await syncSessionArtifacts({
774
764
  baseUrl: input.baseUrl,
775
765
  token: input.token,
776
766
  sessionId: input.sessionId,
777
- artifactRoot: input.artifactRoot
767
+ artifactRoot
778
768
  });
779
769
  } catch (error) {
780
770
  process.stderr.write(
@@ -782,16 +772,14 @@ async function prepareArtifactEnvForShell(input) {
782
772
  `
783
773
  );
784
774
  }
785
- const artifactsDir = sessionArtifactDir(input.artifactRoot, input.sessionId);
775
+ const artifactsDir = sessionArtifactDir(artifactRoot, input.sessionId);
786
776
  fs.mkdirSync(artifactsDir, { recursive: true });
787
- return {
788
- R5D_ARTIFACTS_DIR: artifactsDir
789
- };
777
+ return env;
790
778
  }
791
779
  function preparePlanEnvForShell(input) {
792
- const plansDir = targetPlanDir(input.planRoot, input.target);
780
+ const plansDir = targetPlanDir(path.join(input.rootDir, "plans"), input.target);
793
781
  fs.mkdirSync(plansDir, { recursive: true });
794
- return planEnv(input.planRoot, input.target, input.activePlanId);
782
+ return planEnv(input.activePlanId);
795
783
  }
796
784
  async function verifyR5dctlAuth(baseUrl, token, workerLabel) {
797
785
  const statusUrl = new URL("/api/r5dctl/auth/status", baseUrl);
@@ -959,30 +947,17 @@ function resolveVirtualWorkerFilePath(inputPath, parsed, builtInPaths) {
959
947
  if (parsed.relativePath.split("/").includes("..")) {
960
948
  throw new Error(`Invalid ${parsed.ref} path: ${inputPath}`);
961
949
  }
962
- let rootPath;
963
- let absolutePath;
964
- if (parsed.ref === R5D_ARTIFACTS_DIR_REF) {
965
- rootPath = builtInPaths?.artifactsDir;
966
- } else if (parsed.ref === R5D_PLANS_DIR_REF) {
967
- rootPath = builtInPaths?.plansDir;
968
- } else {
969
- if (parsed.relativePath) {
970
- throw new Error(`${R5D_ACTIVE_PLAN_FILE_REF} must reference the active plan file directly`);
971
- }
972
- rootPath = builtInPaths?.plansDir;
973
- absolutePath = builtInPaths?.activePlanFile;
974
- }
975
- if (!rootPath || parsed.ref === R5D_ACTIVE_PLAN_FILE_REF && !absolutePath) {
976
- const requirement = parsed.ref === R5D_ARTIFACTS_DIR_REF ? "an active chat session" : "an active plan";
977
- throw new Error(`${parsed.ref} paths require ${requirement}`);
978
- }
979
- const normalizedPath = parsed.relativePath ? path.posix.normalize(parsed.relativePath).replace(/^\/+|\/+$/g, "") : "";
950
+ const rootPath = builtInPaths?.rootDir;
951
+ if (!rootPath) throw new Error(`${R5D_ROOT_REF} paths require an available worker root`);
952
+ const expandedPath = expandRootPathSegments(parsed.relativePath, builtInPaths);
953
+ if (expandedPath.includes("..")) throw new Error(`Invalid ${parsed.ref} path: ${inputPath}`);
954
+ const normalizedPath = expandedPath.length > 0 ? path.posix.normalize(expandedPath.join("/")).replace(/^\/+|\/+$/g, "") : "";
980
955
  const normalizedRelativePath = normalizedPath === "." ? "" : normalizedPath;
981
- absolutePath ??= normalizedRelativePath ? path.resolve(rootPath, ...normalizedRelativePath.split("/")) : path.resolve(rootPath);
956
+ const absolutePath = normalizedRelativePath ? path.resolve(rootPath, ...normalizedRelativePath.split("/")) : path.resolve(rootPath);
982
957
  assertPathInsideRealRoot(rootPath, absolutePath, `${parsed.ref} path`);
983
958
  return {
984
959
  absolutePath,
985
- displayPath: normalizedRelativePath ? `${parsed.ref}/${normalizedRelativePath}` : parsed.ref,
960
+ displayPath: parsed.relativePath ? `${parsed.ref}/${parsed.relativePath}` : parsed.ref,
986
961
  repoRelativePath: null,
987
962
  scope: "virtual",
988
963
  virtualRootPath: path.resolve(rootPath)
@@ -2226,8 +2201,7 @@ async function executeReadFileOperation(input) {
2226
2201
  token: input.token,
2227
2202
  sessionId: input.message.sessionId,
2228
2203
  activePlanId: input.message.activePlanId,
2229
- artifactRoot: input.artifactRoot,
2230
- planRoot: input.planRoot,
2204
+ rootDir: input.rootDir,
2231
2205
  access: "read"
2232
2206
  });
2233
2207
  return readWorkerTextFile(input.resolvedTarget.rootPath, input.message.filePath, input.message.offset, input.message.limit, builtInPaths);
@@ -2240,8 +2214,7 @@ async function executeWriteFileOperation(input) {
2240
2214
  token: input.token,
2241
2215
  sessionId: input.message.sessionId,
2242
2216
  activePlanId: input.message.activePlanId,
2243
- artifactRoot: input.artifactRoot,
2244
- planRoot: input.planRoot,
2217
+ rootDir: input.rootDir,
2245
2218
  access: "write"
2246
2219
  });
2247
2220
  input.assertAdmission();
@@ -2259,8 +2232,7 @@ async function executeEditFileOperation(input) {
2259
2232
  token: input.token,
2260
2233
  sessionId: input.message.sessionId,
2261
2234
  activePlanId: input.message.activePlanId,
2262
- artifactRoot: input.artifactRoot,
2263
- planRoot: input.planRoot,
2235
+ rootDir: input.rootDir,
2264
2236
  access: "write"
2265
2237
  });
2266
2238
  input.assertAdmission();
@@ -2387,8 +2359,7 @@ async function executeGrepOperation(input) {
2387
2359
  token: input.token,
2388
2360
  sessionId: input.message.sessionId,
2389
2361
  activePlanId: input.message.activePlanId,
2390
- artifactRoot: input.artifactRoot,
2391
- planRoot: input.planRoot,
2362
+ rootDir: input.rootDir,
2392
2363
  access: "read"
2393
2364
  });
2394
2365
  return grepWorkerFiles(
@@ -2446,8 +2417,7 @@ async function executeFindOperation(input) {
2446
2417
  token: input.token,
2447
2418
  sessionId: input.message.sessionId,
2448
2419
  activePlanId: input.message.activePlanId,
2449
- artifactRoot: input.artifactRoot,
2450
- planRoot: input.planRoot,
2420
+ rootDir: input.rootDir,
2451
2421
  access: "read"
2452
2422
  });
2453
2423
  return findWorkerFiles(
@@ -2488,8 +2458,7 @@ async function executeLsOperation(input) {
2488
2458
  token: input.token,
2489
2459
  sessionId: input.message.sessionId,
2490
2460
  activePlanId: input.message.activePlanId,
2491
- artifactRoot: input.artifactRoot,
2492
- planRoot: input.planRoot,
2461
+ rootDir: input.rootDir,
2493
2462
  access: "read"
2494
2463
  });
2495
2464
  return listWorkerDirectory(input.resolvedTarget.rootPath, input.message.path, input.message.limit, builtInPaths);
@@ -2595,8 +2564,7 @@ async function executeViewFileBytesOperation(input) {
2595
2564
  token: input.token,
2596
2565
  sessionId: input.message.sessionId,
2597
2566
  activePlanId: input.message.activePlanId,
2598
- artifactRoot: input.artifactRoot,
2599
- planRoot: input.planRoot,
2567
+ rootDir: input.rootDir,
2600
2568
  access: "read"
2601
2569
  });
2602
2570
  return readWorkerImageFile(input.resolvedTarget.rootPath, input.message.filePath, builtInPaths);
@@ -2636,27 +2604,30 @@ function targetIdentityEnv(target) {
2636
2604
  R5D_BRANCH_NAME: ""
2637
2605
  };
2638
2606
  }
2607
+ function targetProcessEnv(rootDir, target) {
2608
+ return { [R5D_ROOT_ENV]: rootDir, ...targetIdentityEnv(target) };
2609
+ }
2639
2610
  async function prepareShellEnvForTarget(input) {
2640
2611
  const artifactProcessEnv = await prepareArtifactEnvForShell({
2641
2612
  baseUrl: input.baseUrl,
2642
2613
  token: input.token,
2643
2614
  sessionId: input.sessionId,
2644
- artifactRoot: input.artifactRoot
2615
+ rootDir: input.rootDir
2645
2616
  });
2646
2617
  if (input.target.type === "workspace" && input.target.rootProfile === "canonical_sync") {
2647
2618
  return {
2648
2619
  ...artifactProcessEnv,
2649
- ...targetIdentityEnv(input.target)
2620
+ ...targetProcessEnv(input.rootDir, input.target)
2650
2621
  };
2651
2622
  }
2652
2623
  return {
2653
2624
  ...artifactProcessEnv,
2654
2625
  ...preparePlanEnvForShell({
2655
2626
  target: input.target,
2656
- planRoot: input.planRoot,
2627
+ rootDir: input.rootDir,
2657
2628
  activePlanId: input.activePlanId
2658
2629
  }),
2659
- ...targetIdentityEnv(input.target)
2630
+ ...targetProcessEnv(input.rootDir, input.target)
2660
2631
  };
2661
2632
  }
2662
2633
  async function executeCommand(input) {
@@ -2671,13 +2642,12 @@ async function executeCommand(input) {
2671
2642
  };
2672
2643
  input.setPreparationHeartbeatLease(true);
2673
2644
  try {
2674
- const targetProcessEnv = await prepareShellEnvForTarget({
2645
+ const targetProcessEnv2 = await prepareShellEnvForTarget({
2675
2646
  target: input.resolvedTarget.target,
2676
2647
  baseUrl: input.baseUrl,
2677
2648
  token: input.token,
2678
2649
  sessionId: input.message.sessionId,
2679
- artifactRoot: input.artifactRoot,
2680
- planRoot: input.planRoot,
2650
+ rootDir: input.rootDir,
2681
2651
  activePlanId: input.message.env?.R5D_ACTIVE_PLAN_ID
2682
2652
  });
2683
2653
  const cwd = resolveCommandCwd(input.resolvedTarget.rootPath, input.message.cwd);
@@ -2687,7 +2657,7 @@ async function executeCommand(input) {
2687
2657
  stdout: "pipe",
2688
2658
  stderr: "pipe",
2689
2659
  detached: true,
2690
- env: workerChildProcessEnvironment([githubProcessEnv(), input.message.env ?? {}, targetProcessEnv])
2660
+ env: workerChildProcessEnvironment([githubProcessEnv(), input.message.env ?? {}, targetProcessEnv2])
2691
2661
  });
2692
2662
  spawnedProcess = subprocess;
2693
2663
  credentialBearingProcessGroups.set(subprocess.pid, subprocess);
@@ -2776,13 +2746,12 @@ async function executeStreamingCommand(input) {
2776
2746
  });
2777
2747
  return;
2778
2748
  }
2779
- const targetProcessEnv = await prepareShellEnvForTarget({
2749
+ const targetProcessEnv2 = await prepareShellEnvForTarget({
2780
2750
  target: input.resolvedTarget.target,
2781
2751
  baseUrl: input.baseUrl,
2782
2752
  token: input.token,
2783
2753
  sessionId: input.message.sessionId,
2784
- artifactRoot: input.artifactRoot,
2785
- planRoot: input.planRoot,
2754
+ rootDir: input.rootDir,
2786
2755
  activePlanId: input.message.env?.R5D_ACTIVE_PLAN_ID
2787
2756
  });
2788
2757
  const cwd = resolveCommandCwd(input.resolvedTarget.rootPath, input.message.cwd);
@@ -2796,7 +2765,7 @@ async function executeStreamingCommand(input) {
2796
2765
  stdout: "pipe",
2797
2766
  stderr: "pipe",
2798
2767
  detached: true,
2799
- env: workerChildProcessEnvironment([githubProcessEnv(), input.message.env ?? {}, targetProcessEnv])
2768
+ env: workerChildProcessEnvironment([githubProcessEnv(), input.message.env ?? {}, targetProcessEnv2])
2800
2769
  });
2801
2770
  spawnedProcess = subprocess;
2802
2771
  credentialBearingProcessGroups.set(subprocess.pid, subprocess);
@@ -3260,10 +3229,10 @@ async function openPty(input) {
3260
3229
  }
3261
3230
  const planProcessEnv = !(input.resolvedTarget.target.type === "workspace" && input.resolvedTarget.target.rootProfile === "canonical_sync") ? preparePlanEnvForShell({
3262
3231
  target: input.resolvedTarget.target,
3263
- planRoot: input.planRoot,
3232
+ rootDir: input.rootDir,
3264
3233
  activePlanId: input.message.env?.R5D_ACTIVE_PLAN_ID
3265
3234
  }) : {};
3266
- const targetProcessEnv = targetIdentityEnv(input.resolvedTarget.target);
3235
+ const targetEnv = targetProcessEnv(input.rootDir, input.resolvedTarget.target);
3267
3236
  const shell = resolveHostShell(input.message.command);
3268
3237
  const outputCoalescer = createPtyOutputCoalescer({
3269
3238
  send: (data) => {
@@ -3295,7 +3264,7 @@ async function openPty(input) {
3295
3264
  githubProcessEnv(),
3296
3265
  resolvePtyEnvFileReferences(input.message.env ?? {}, stagedEnvFiles),
3297
3266
  planProcessEnv,
3298
- targetProcessEnv,
3267
+ targetEnv,
3299
3268
  shell.env ?? {}
3300
3269
  ])
3301
3270
  },
@@ -3468,12 +3437,13 @@ async function startWorker(options) {
3468
3437
  initializeCredentialStoreDirectoryOnce();
3469
3438
  const label = requireValue(options.label, "Missing required --label <label>");
3470
3439
  validateLabel(label);
3471
- const projectsRoot = path.resolve(options.projectRoot ?? defaultProjectsRoot());
3472
- const syncRoot = path.resolve(options.syncRoot ?? process.env.R5D_SYNC_ROOT ?? defaultSyncRoot());
3440
+ const rootDir = path.resolve(options.rootDir ?? process.env.R5D_ROOT ?? defaultRootDir());
3441
+ const projectsRoot = path.join(rootDir, "projects");
3442
+ const syncRoot = path.join(rootDir, "sync");
3473
3443
  const workspaceShadowRoot = path.join(syncRoot, "workspace");
3474
3444
  const projectWorktreeSnapshotsRoot = path.join(syncRoot, "project-worktree-snapshots");
3475
- const artifactRoot = path.resolve(options.artifactRoot ?? process.env.R5D_ARTIFACTS_ROOT ?? defaultArtifactRoot());
3476
- const planRoot = path.resolve(options.planRoot ?? process.env.R5D_PLANS_ROOT ?? defaultPlanRoot());
3445
+ const artifactRoot = path.join(rootDir, "artifacts");
3446
+ const planRoot = path.join(rootDir, "plans");
3477
3447
  assertDisjointManagedRoots([
3478
3448
  { label: "projects root", rootPath: projectsRoot },
3479
3449
  { label: "sync root", rootPath: syncRoot },
@@ -3482,13 +3452,7 @@ async function startWorker(options) {
3482
3452
  ]);
3483
3453
  process.stdout.write(`[r5d-worker] label: ${label}
3484
3454
  `);
3485
- process.stdout.write(`[r5d-worker] projects root: ${projectsRoot}
3486
- `);
3487
- process.stdout.write(`[r5d-worker] sync root: ${syncRoot}
3488
- `);
3489
- process.stdout.write(`[r5d-worker] artifact root: ${artifactRoot}
3490
- `);
3491
- process.stdout.write(`[r5d-worker] plan root: ${planRoot}
3455
+ process.stdout.write(`[r5d-worker] root: ${rootDir}
3492
3456
  `);
3493
3457
  process.stdout.write(`[r5d-worker] server: ${baseUrl}
3494
3458
  `);
@@ -3513,9 +3477,29 @@ async function startWorker(options) {
3513
3477
  const reconciledProjectConfigFingerprints = /* @__PURE__ */ new Map();
3514
3478
  const lastObservedProjectHeads = /* @__PURE__ */ new Map();
3515
3479
  const advertisedProjectMirrorRefsTokens = /* @__PURE__ */ new Map();
3480
+ const advertisedProjectMirrorMountTokens = /* @__PURE__ */ new Map();
3481
+ let advertisedWorkspaceHead;
3516
3482
  const fetchedProjectMirrorRefsTokens = /* @__PURE__ */ new Map();
3517
- const lastProjectMirrorFetchAtMs = /* @__PURE__ */ new Map();
3483
+ const fetchedProjectMirrorMountTokens = /* @__PURE__ */ new Map();
3518
3484
  const projectMirrorFetchSkippingEnabled = process.env.R5D_PROJECT_MIRROR_FETCH_SKIP !== "0";
3485
+ const forgetLocalProjectMirrorRefsTokens = (projectId, branchName) => {
3486
+ fetchedProjectMirrorRefsTokens.delete(projectId);
3487
+ if (branchName === void 0) fetchedProjectMirrorMountTokens.delete(projectId);
3488
+ else fetchedProjectMirrorMountTokens.get(projectId)?.delete(branchName);
3489
+ };
3490
+ const recordLocalProjectMirrorRefsTokens = async (project) => {
3491
+ try {
3492
+ const tokens = await computeLocalProjectMirrorRefsTokens({
3493
+ projectRoot: configuredProjectRoot(projectsRoot, project),
3494
+ primaryBranchName: primaryProjectBranch(project),
3495
+ mounts: project.branches.map(({ branchId, branchName }) => ({ branchId, branchName }))
3496
+ });
3497
+ fetchedProjectMirrorRefsTokens.set(project.projectId, tokens.project);
3498
+ fetchedProjectMirrorMountTokens.set(project.projectId, tokens.mounts);
3499
+ } catch {
3500
+ forgetLocalProjectMirrorRefsTokens(project.projectId);
3501
+ }
3502
+ };
3519
3503
  const pendingMirrorDeletes = /* @__PURE__ */ new Map();
3520
3504
  let workspaceRemoteUrl = null;
3521
3505
  let workspaceCredentialHelper = null;
@@ -3530,6 +3514,7 @@ async function startWorker(options) {
3530
3514
  let workspacePeriodicTimer;
3531
3515
  let pendingAutomaticTrigger;
3532
3516
  const pendingCreatedBranchPublicationNotBefore = /* @__PURE__ */ new Map();
3517
+ const workspaceProjectionLedger = new WorkspaceProjectionLedger();
3533
3518
  let automaticSyncInFlight = false;
3534
3519
  let terminalReplayTimer;
3535
3520
  let workspaceSyncRequestsInFlight = 0;
@@ -3899,7 +3884,7 @@ async function startWorker(options) {
3899
3884
  workspaceRelativePath: "workspace-plans",
3900
3885
  sourceMode: "all",
3901
3886
  hydrateDeletionMode: "all",
3902
- // Workspace-level agents receive this directory through R5D_PLANS_DIR.
3887
+ // Workspace-level agents reach this directory through $R5D_ROOT/plans.
3903
3888
  // Keep both projection and hydration away from it while any visible-
3904
3889
  // projects process, PTY, or built-in write/edit is active or reserved.
3905
3890
  busy: () => {
@@ -3937,7 +3922,10 @@ async function startWorker(options) {
3937
3922
  deleteWhenSourceMissing: true
3938
3923
  });
3939
3924
  }
3940
- return mounts;
3925
+ return mounts.map((mount) => {
3926
+ const lastProjectedMutationToken = workspaceProjectionLedger.lastProjectedToken(mount);
3927
+ return lastProjectedMutationToken === void 0 ? mount : { ...mount, lastProjectedMutationToken };
3928
+ });
3941
3929
  };
3942
3930
  const workspaceLocalHead = async () => {
3943
3931
  if (!fs.existsSync(path.join(workspaceShadowRoot, ".git"))) return null;
@@ -4162,17 +4150,7 @@ async function startWorker(options) {
4162
4150
  for (const result of results) {
4163
4151
  lastObservedProjectHeads.set(projectBranchKey(project.projectId, result.branchName), result.head);
4164
4152
  }
4165
- try {
4166
- fetchedProjectMirrorRefsTokens.set(
4167
- project.projectId,
4168
- await computeLocalProjectMirrorRefsToken({
4169
- projectRoot: configuredProjectRoot(projectsRoot, project),
4170
- primaryBranchName: primaryProjectBranch(project)
4171
- })
4172
- );
4173
- } catch {
4174
- fetchedProjectMirrorRefsTokens.delete(project.projectId);
4175
- }
4153
+ await recordLocalProjectMirrorRefsTokens(project);
4176
4154
  }
4177
4155
  const publishedTombstoneIds = [
4178
4156
  ...new Set(
@@ -4198,7 +4176,7 @@ async function startWorker(options) {
4198
4176
  credentialHelper: deletion.credentialHelper,
4199
4177
  credentialUsername: deletion.credentialUsername
4200
4178
  });
4201
- fetchedProjectMirrorRefsTokens.delete(deletion.projectId);
4179
+ forgetLocalProjectMirrorRefsTokens(deletion.projectId, deletion.branchName);
4202
4180
  }
4203
4181
  if (deletion.tombstoneId && !deletion.projectDeleted) {
4204
4182
  projectWorkspaceState = projectWorkspaceStateStore.recordMirrorRefDeletion({
@@ -4210,8 +4188,8 @@ async function startWorker(options) {
4210
4188
  if (deletion.projectDeleted && !projectConfigById.has(deletion.projectId) && ![...pendingMirrorDeletes.values()].some((pending) => pending.projectId === deletion.projectId)) {
4211
4189
  removeProjectCredentialStore(deletion.projectId);
4212
4190
  advertisedProjectMirrorRefsTokens.delete(deletion.projectId);
4213
- fetchedProjectMirrorRefsTokens.delete(deletion.projectId);
4214
- lastProjectMirrorFetchAtMs.delete(deletion.projectId);
4191
+ advertisedProjectMirrorMountTokens.delete(deletion.projectId);
4192
+ forgetLocalProjectMirrorRefsTokens(deletion.projectId);
4215
4193
  }
4216
4194
  lastObservedProjectHeads.delete(key);
4217
4195
  }
@@ -4220,39 +4198,20 @@ async function startWorker(options) {
4220
4198
  const connection = projectConnection(project);
4221
4199
  const projectRoot = configuredProjectRoot(projectsRoot, project);
4222
4200
  const primaryBranchName = primaryProjectBranch(project);
4223
- if (options2.refreshRemote) {
4224
- const advertisedBeforeFetch = advertisedProjectMirrorRefsTokens.get(project.projectId);
4225
- const observations = await observeProjectMirrorHeads({
4226
- projectRoot,
4227
- primaryBranchName,
4228
- branchNames: project.branches.map(({ branchName }) => branchName),
4229
- mirrorUrl: connection.mirrorUrl,
4230
- credentialHelper: connection.credentialHelper,
4231
- credentialUsername: connection.mirrorCredentialUsername,
4232
- allowNonFastForward: options2.allowNonFastForward,
4233
- branchIds: new Map(project.branches.map(({ branchId, branchName }) => [branchName, branchId]))
4234
- });
4235
- if (advertisedBeforeFetch === void 0) fetchedProjectMirrorRefsTokens.delete(project.projectId);
4236
- else fetchedProjectMirrorRefsTokens.set(project.projectId, advertisedBeforeFetch);
4237
- lastProjectMirrorFetchAtMs.set(project.projectId, Date.now());
4238
- return { projectId: project.projectId, projectRoot, primaryBranchName, observations };
4239
- }
4240
- return {
4241
- projectId: project.projectId,
4201
+ const observations = await observeProjectMirrorHeads({
4242
4202
  projectRoot,
4243
4203
  primaryBranchName,
4244
- observations: await observeProjectMirrorHeads({
4245
- projectRoot,
4246
- primaryBranchName,
4247
- branchNames: project.branches.map(({ branchName }) => branchName),
4248
- mirrorUrl: connection.mirrorUrl,
4249
- credentialHelper: connection.credentialHelper,
4250
- credentialUsername: connection.mirrorCredentialUsername,
4251
- allowNonFastForward: options2.allowNonFastForward,
4252
- branchIds: new Map(project.branches.map(({ branchId, branchName }) => [branchName, branchId])),
4253
- refreshRemote: false
4254
- })
4255
- };
4204
+ branchNames: project.branches.map(({ branchName }) => branchName),
4205
+ mirrorUrl: connection.mirrorUrl,
4206
+ credentialHelper: connection.credentialHelper,
4207
+ credentialUsername: connection.mirrorCredentialUsername,
4208
+ allowNonFastForward: options2.allowNonFastForward,
4209
+ branchIds: new Map(project.branches.map(({ branchId, branchName }) => [branchName, branchId])),
4210
+ refreshRemote: options2.fetch.scope !== "none",
4211
+ ...options2.fetch.scope === "mounts" ? { fetchOnlyBranches: new Set(options2.fetch.mounts.map(({ branchName }) => branchName)) } : {}
4212
+ });
4213
+ if (options2.fetch.scope !== "none") await recordLocalProjectMirrorRefsTokens(project);
4214
+ return { projectId: project.projectId, projectRoot, primaryBranchName, observations };
4256
4215
  };
4257
4216
  const observeProjectHeadsBeforeOuterWorkspace = async (input) => {
4258
4217
  const observationStartedAt = Date.now();
@@ -4263,28 +4222,39 @@ async function startWorker(options) {
4263
4222
  if (input.failClosed) {
4264
4223
  const bundles2 = [];
4265
4224
  for (const project of eligibleProjects) {
4266
- bundles2.push(await observeSingleProjectMirrorBundle(project, { allowNonFastForward: input.allowNonFastForward, refreshRemote: true }));
4225
+ bundles2.push(
4226
+ await observeSingleProjectMirrorBundle(project, {
4227
+ allowNonFastForward: input.allowNonFastForward,
4228
+ fetch: { scope: "project", reason: "fail_closed" }
4229
+ })
4230
+ );
4267
4231
  }
4268
4232
  return bundles2;
4269
4233
  }
4270
- let fetchedCount = 0;
4234
+ let wholeMirrorFetches = 0;
4235
+ let scopedMirrorFetches = 0;
4236
+ let fetchedMounts = 0;
4237
+ let eligibleMounts = 0;
4271
4238
  const bundles = await mapWithConcurrencyLimit(
4272
4239
  eligibleProjects,
4273
4240
  PROJECT_MIRROR_OBSERVE_CONCURRENCY,
4274
4241
  async (project) => {
4275
4242
  try {
4276
- const decision = projectMirrorFetchDecision({
4277
- advertisedToken: advertisedProjectMirrorRefsTokens.get(project.projectId),
4278
- fetchedToken: fetchedProjectMirrorRefsTokens.get(project.projectId),
4279
- lastFetchAtMs: lastProjectMirrorFetchAtMs.get(project.projectId),
4280
- nowMs: Date.now(),
4243
+ const fetch2 = planProjectMirrorObservationFetch({
4244
+ branchNames: project.branches.map(({ branchName }) => branchName),
4245
+ advertisedProjectToken: advertisedProjectMirrorRefsTokens.get(project.projectId),
4246
+ advertisedMountTokens: advertisedProjectMirrorMountTokens.get(project.projectId),
4247
+ fetchedProjectToken: fetchedProjectMirrorRefsTokens.get(project.projectId),
4248
+ fetchedMountTokens: fetchedProjectMirrorMountTokens.get(project.projectId) ?? /* @__PURE__ */ new Map(),
4281
4249
  skippingEnabled: projectMirrorFetchSkippingEnabled
4282
4250
  });
4283
- if (decision.fetch) fetchedCount += 1;
4284
- return await observeSingleProjectMirrorBundle(project, {
4285
- allowNonFastForward: input.allowNonFastForward,
4286
- refreshRemote: decision.fetch
4287
- });
4251
+ eligibleMounts += project.branches.length;
4252
+ if (fetch2.scope === "project") wholeMirrorFetches += 1;
4253
+ if (fetch2.scope === "mounts") {
4254
+ scopedMirrorFetches += 1;
4255
+ fetchedMounts += fetch2.mounts.length;
4256
+ }
4257
+ return await observeSingleProjectMirrorBundle(project, { allowNonFastForward: input.allowNonFastForward, fetch: fetch2 });
4288
4258
  } catch (error) {
4289
4259
  process.stderr.write(
4290
4260
  `[r5d-worker] failed to observe project mirror heads before workspace update: ${error instanceof Error ? error.message : String(error)}
@@ -4294,9 +4264,9 @@ async function startWorker(options) {
4294
4264
  }
4295
4265
  }
4296
4266
  );
4297
- if (fetchedCount > 0) {
4267
+ if (wholeMirrorFetches + scopedMirrorFetches > 0) {
4298
4268
  process.stderr.write(
4299
- `[r5d-worker] project mirror observation fetched ${fetchedCount} of ${eligibleProjects.length} mirror(s)
4269
+ `[r5d-worker] project mirror observation fetched ${wholeMirrorFetches + scopedMirrorFetches} of ${eligibleProjects.length} mirror(s) (${wholeMirrorFetches} whole, ${scopedMirrorFetches} scoped to ${fetchedMounts} of ${eligibleMounts} mount(s))
4300
4270
  `
4301
4271
  );
4302
4272
  }
@@ -4410,6 +4380,7 @@ async function startWorker(options) {
4410
4380
  }
4411
4381
  const configuredMounts = buildWorkspaceMounts();
4412
4382
  if (input.resetToCanonical) {
4383
+ workspaceProjectionLedger.reset();
4413
4384
  const resetHasBusyLiveMount = configuredMounts.some(
4414
4385
  (mount) => !(mount.deleteWhenSourceMissing && !fs.existsSync(mount.sourcePath)) && mount.busy?.()
4415
4386
  );
@@ -4452,6 +4423,7 @@ async function startWorker(options) {
4452
4423
  }
4453
4424
  const outerRemediation = input.trigger.type === "remediation" || input.trigger.type === "remediation_confirm";
4454
4425
  const mounts = outerRemediation ? workspaceGitMountsForRemediation(configuredMounts) : configuredMounts;
4426
+ if (outerRemediation) workspaceProjectionLedger.reset();
4455
4427
  const observedProjectHeads = outerRemediation ? [] : await observeProjectHeadsBeforeOuterWorkspace({ allowNonFastForward: false, failClosed: false });
4456
4428
  const expectedMirrorHeads = /* @__PURE__ */ new Map();
4457
4429
  for (const bundle of observedProjectHeads) {
@@ -4478,6 +4450,10 @@ async function startWorker(options) {
4478
4450
  allowLargeDiff: input.confirmedLargeDiff,
4479
4451
  skipMountMirror: outerRemediation,
4480
4452
  requiredAncestorHeads: input.requiredAncestorHeads,
4453
+ // Same gate as the mirror fetches: while the server's read of the hidden
4454
+ // workspace head equals the head this clone last fetched or pushed, the
4455
+ // cycle's workspace head fetches would transfer nothing.
4456
+ advertisedRemoteHead: projectMirrorFetchSkippingEnabled && !outerRemediation ? advertisedWorkspaceHead : void 0,
4481
4457
  assertStillAdmitted: input.assertStillAdmitted,
4482
4458
  afterWorkspacePublished: outerRemediation ? void 0 : async ({ activeMountIds, publishedHead }) => {
4483
4459
  await pushChangedProjectHeads(activeMountIds, publishedHead, publicationHeads, expectedMirrorHeads, inboundMoveMountIds);
@@ -4499,7 +4475,11 @@ async function startWorker(options) {
4499
4475
  result.localHead ?? result.remoteHead
4500
4476
  );
4501
4477
  }
4502
- return mapWorkspaceGitResult(input.attemptId, input.trigger, result);
4478
+ const mapped = await mapWorkspaceGitResult(input.attemptId, input.trigger, result);
4479
+ if (!outerRemediation) {
4480
+ workspaceProjectionLedger.recordCycle({ result: mapped, mounts, projectedMutationTokens: result.projectedMutationTokens });
4481
+ }
4482
+ return mapped;
4503
4483
  };
4504
4484
  const sendWorkspaceSyncResult = (requestId, result) => {
4505
4485
  try {
@@ -4537,17 +4517,143 @@ async function startWorker(options) {
4537
4517
  }, unsafeWorkspaceSyncRecoveryBounceDelayMs(attempt));
4538
4518
  timer.unref();
4539
4519
  };
4520
+ const captureWorkspaceSyncAdmission = () => ({
4521
+ generation: workerAdmissionGeneration,
4522
+ activeIncidentId: activeWorkspaceIncidentId,
4523
+ deferredIncidentId: deferredWorkspaceConfiguration?.incidentId ?? null
4524
+ });
4525
+ const workspaceSyncAdmissionSnapshotsMatch = (left, right) => left.generation === right.generation && left.activeIncidentId === right.activeIncidentId && left.deferredIncidentId === right.deferredIncidentId;
4526
+ const assertWorkspaceSyncStillAdmitted = (admitted, trigger) => {
4527
+ if (!workspaceSyncAdmissionSnapshotsMatch(admitted, captureWorkspaceSyncAdmission()) || currentWorkerSocket !== ws || ws.readyState !== WebSocket.OPEN || !deferredWorkspaceSyncTriggerIsAllowed(deferredWorkspaceConfiguration, activeWorkspaceIncidentId, trigger)) {
4528
+ throw new WorkspaceSyncAdmissionChangedError("Workspace synchronization admission changed while asynchronous work was in flight");
4529
+ }
4530
+ };
4531
+ const executeWorkspaceSyncCycle = async (input) => {
4532
+ const attemptId = input.attemptId;
4533
+ lastWorkspaceSyncMirrorObservationMs = 0;
4534
+ if (!deferredWorkspaceSyncTriggerIsAllowed(deferredWorkspaceConfiguration, activeWorkspaceIncidentId, input.trigger)) {
4535
+ return await failedWorkspaceSyncResult(
4536
+ attemptId,
4537
+ input.trigger,
4538
+ new Error(
4539
+ `Workspace synchronization was fenced while queued by incident ${deferredWorkspaceConfiguration?.incidentId ?? activeWorkspaceIncidentId ?? "unknown"}`
4540
+ )
4541
+ );
4542
+ }
4543
+ try {
4544
+ input.assertStillAdmitted();
4545
+ const hasDurableBranchDeletion = projectWorkspaceState.tombstones.some((tombstone) => tombstone.kind === "branch");
4546
+ return await performWorkspaceSync({
4547
+ attemptId,
4548
+ trigger: input.trigger,
4549
+ confirmedLargeDiff: input.confirmedLargeDiff === true || hasDurableBranchDeletion,
4550
+ confirmationReason: input.confirmationReason ?? (hasDurableBranchDeletion ? "durable project branch deletion" : void 0),
4551
+ resetToCanonical: input.resetToCanonical,
4552
+ requiredAncestorHeads: input.requiredAncestorHeads,
4553
+ assertStillAdmitted: input.assertStillAdmitted
4554
+ });
4555
+ } catch (error) {
4556
+ workspaceProjectionLedger.reset();
4557
+ const errorMessage = error instanceof Error ? error.message : String(error);
4558
+ const hydrationRecoverable = workspaceSyncFailureHydrationIsSafe({
4559
+ error,
4560
+ resetToCanonical: input.resetToCanonical === true,
4561
+ inspectRecoverableHydration: () => workspaceGitHydrationIsRecoverable(workspaceShadowRoot, buildWorkspaceMounts())
4562
+ });
4563
+ if (hydrationRecoverable) {
4564
+ if (error instanceof WorkspaceSyncAdmissionChangedError) {
4565
+ process.stderr.write(
4566
+ `[r5d-worker] workspace synchronization was cancelled with every mount at a recorded basis; the next cycle converges it: ${errorMessage}
4567
+ `
4568
+ );
4569
+ }
4570
+ return await failedWorkspaceSyncResult(attemptId, input.trigger, error);
4571
+ }
4572
+ const fenced = fenceUnsafeWorkspaceSyncFailure({
4573
+ hydrationRecoverable,
4574
+ resetToCanonical: input.resetToCanonical === true,
4575
+ recoveryAttempts: unsafeWorkspaceSyncRecoveryAttempts,
4576
+ maxRecoveryAttempts: WORKSPACE_UNSAFE_SYNC_MAX_RECOVERY_ATTEMPTS,
4577
+ hasRetainableWork: activeProcesses.size > 0 || credentialBearingProcessGroups.size > 0 || activePtys.size > 0 || pendingProcessTerminals.size > 0,
4578
+ bouncePending: unsafeWorkspaceSyncBouncePending,
4579
+ invalidateExecution: () => {
4580
+ workspaceConfigured = false;
4581
+ advanceWorkerAdmissionGeneration();
4582
+ },
4583
+ scheduleRecoveryBounce: () => {
4584
+ unsafeWorkspaceSyncRecoveryAttempts += 1;
4585
+ scheduleUnsafeWorkspaceSyncRecoveryBounce(errorMessage);
4586
+ },
4587
+ fatalExit: (exitCode) => {
4588
+ process.stderr.write(
4589
+ `[r5d-worker] workspace synchronization failed without a recoverable hydration basis; exiting for exact recovery after ${unsafeWorkspaceSyncRecoveryAttempts} in-process recovery attempt(s): ${errorMessage}
4590
+ `
4591
+ );
4592
+ process.exit(exitCode);
4593
+ },
4594
+ exitCode: 1
4595
+ });
4596
+ if (fenced === "recovery_pending") {
4597
+ process.stderr.write(
4598
+ `[r5d-worker] workspace synchronization failed without a recoverable hydration basis while in-process recovery is already pending: ${errorMessage}
4599
+ `
4600
+ );
4601
+ }
4602
+ return {
4603
+ ...await failedWorkspaceSyncResult(attemptId, input.trigger, error),
4604
+ failureReason: "unsafe_hydration",
4605
+ recoveryAttempt: unsafeWorkspaceSyncRecoveryAttempts
4606
+ };
4607
+ }
4608
+ };
4609
+ const REMEDIATION_TRIGGER_TYPES = /* @__PURE__ */ new Set(["remediation", "remediation_confirm", "remediation_reset"]);
4610
+ const isOrdinaryWorkspaceSyncRequest = (input) => input.confirmedLargeDiff !== true && input.resetToCanonical !== true && !(input.requiredAncestorHeads && input.requiredAncestorHeads.length > 0) && !REMEDIATION_TRIGGER_TYPES.has(input.trigger.type);
4611
+ const LOCAL_ACTIVITY_TRIGGER_TYPES = /* @__PURE__ */ new Set([
4612
+ "write",
4613
+ "edit",
4614
+ "shell_inline",
4615
+ "process_terminal",
4616
+ "process_cancel"
4617
+ ]);
4618
+ const answerOrdinaryWorkspaceSyncFromLastCycle = (requests) => {
4619
+ if (!workspaceConfigured) return null;
4620
+ return workspaceProjectionLedger.answerFromLastCycle({
4621
+ // A workspace-rooted command carries no project target because it may
4622
+ // have touched any checkout.
4623
+ targetMountIds: requests.map(
4624
+ ({ trigger }) => LOCAL_ACTIVITY_TRIGGER_TYPES.has(trigger.type) && trigger.projectId && trigger.branchName ? projectMountId(trigger.projectId, trigger.branchName) : null
4625
+ ),
4626
+ mounts: buildWorkspaceMounts(),
4627
+ pendingDeletions: pendingMirrorDeletes.size > 0 || projectWorkspaceState.pendingTreeDeletions.length > 0 || projectWorkspaceState.pendingMirrorRefDeletions.length > 0
4628
+ });
4629
+ };
4630
+ const ordinaryWorkspaceSyncCoalescer = new WorkspaceSyncCoalescer({
4631
+ runExclusive: (operation) => workspaceSyncSingleFlight.runExclusive(operation),
4632
+ runBatch: async (requests) => {
4633
+ const startedAtMs = Date.now();
4634
+ const admission = captureWorkspaceSyncAdmission();
4635
+ const staleAttemptIds = /* @__PURE__ */ new Set();
4636
+ const admitted = [];
4637
+ for (const request of requests) {
4638
+ if (workspaceSyncAdmissionSnapshotsMatch(request.admission, admission)) admitted.push(request);
4639
+ else staleAttemptIds.add(request.attemptId);
4640
+ }
4641
+ const leader = admitted[0];
4642
+ if (!leader) return { result: null, ranCycle: false, startedAtMs, finishedAtMs: Date.now(), prepareMs: 0, staleAttemptIds };
4643
+ const answered = answerOrdinaryWorkspaceSyncFromLastCycle(admitted);
4644
+ if (answered) return { result: answered, ranCycle: false, startedAtMs, finishedAtMs: Date.now(), prepareMs: 0, staleAttemptIds };
4645
+ const result = await executeWorkspaceSyncCycle({
4646
+ attemptId: leader.attemptId,
4647
+ trigger: leader.trigger,
4648
+ assertStillAdmitted: () => assertWorkspaceSyncStillAdmitted(admission, leader.trigger)
4649
+ });
4650
+ return { result, ranCycle: true, startedAtMs, finishedAtMs: Date.now(), prepareMs: lastWorkspaceSyncMirrorObservationMs, staleAttemptIds };
4651
+ }
4652
+ });
4540
4653
  const runWorkspaceSync = async (input) => {
4541
4654
  const attemptId = input.attemptId ?? crypto.randomUUID();
4542
4655
  const requestedAt = Date.now();
4543
- const admittedGeneration = workerAdmissionGeneration;
4544
- const admittedActiveIncidentId = activeWorkspaceIncidentId;
4545
- const admittedDeferredIncidentId = deferredWorkspaceConfiguration?.incidentId ?? null;
4546
- const assertStillAdmitted = () => {
4547
- if (admittedGeneration !== workerAdmissionGeneration || admittedActiveIncidentId !== activeWorkspaceIncidentId || admittedDeferredIncidentId !== (deferredWorkspaceConfiguration?.incidentId ?? null) || currentWorkerSocket !== ws || ws.readyState !== WebSocket.OPEN || !deferredWorkspaceSyncTriggerIsAllowed(deferredWorkspaceConfiguration, activeWorkspaceIncidentId, input.trigger)) {
4548
- throw new WorkspaceSyncAdmissionChangedError("Workspace synchronization admission changed while asynchronous work was in flight");
4549
- }
4550
- };
4656
+ const admission = captureWorkspaceSyncAdmission();
4551
4657
  if (!deferredWorkspaceSyncTriggerIsAllowed(deferredWorkspaceConfiguration, activeWorkspaceIncidentId, input.trigger)) {
4552
4658
  const result2 = {
4553
4659
  ...await failedWorkspaceSyncResult(
@@ -4573,104 +4679,47 @@ async function startWorker(options) {
4573
4679
  }
4574
4680
  ws.send(JSON.stringify({ type: "workspace_sync_started", attemptId }));
4575
4681
  }
4576
- let queueEnteredAt = requestedAt;
4577
- let syncStartedAt = requestedAt;
4682
+ let queueMs = 0;
4683
+ let prepareMs = 0;
4684
+ let synchronizeMs = 0;
4578
4685
  workspaceSyncRequestsInFlight += 1;
4579
4686
  let result;
4580
4687
  try {
4581
- result = await workspaceSyncSingleFlight.runExclusive(async () => {
4582
- queueEnteredAt = Date.now();
4583
- syncStartedAt = Date.now();
4584
- lastWorkspaceSyncMirrorObservationMs = 0;
4585
- if (!deferredWorkspaceSyncTriggerIsAllowed(deferredWorkspaceConfiguration, activeWorkspaceIncidentId, input.trigger)) {
4586
- return await failedWorkspaceSyncResult(
4587
- attemptId,
4588
- input.trigger,
4589
- new Error(
4590
- `Workspace synchronization was fenced while queued by incident ${deferredWorkspaceConfiguration?.incidentId ?? activeWorkspaceIncidentId ?? "unknown"}`
4591
- )
4592
- );
4593
- }
4594
- try {
4595
- assertStillAdmitted();
4596
- const hasDurableBranchDeletion = projectWorkspaceState.tombstones.some((tombstone) => tombstone.kind === "branch");
4597
- return await performWorkspaceSync({
4688
+ if (isOrdinaryWorkspaceSyncRequest(input)) {
4689
+ const settlement = await ordinaryWorkspaceSyncCoalescer.request({ attemptId, trigger: input.trigger, admission });
4690
+ queueMs = settlement.startedAtMs - requestedAt;
4691
+ prepareMs = settlement.prepareMs;
4692
+ synchronizeMs = settlement.finishedAtMs - settlement.startedAtMs;
4693
+ result = settlement.staleAttemptIds.has(attemptId) || settlement.result === null ? await failedWorkspaceSyncResult(
4694
+ attemptId,
4695
+ input.trigger,
4696
+ new Error("Workspace synchronization admission changed while the request was queued")
4697
+ ) : { ...settlement.result, attemptId, trigger: input.trigger };
4698
+ } else {
4699
+ result = await workspaceSyncSingleFlight.runExclusive(async () => {
4700
+ const startedAt = Date.now();
4701
+ queueMs = startedAt - requestedAt;
4702
+ const cycleResult = await executeWorkspaceSyncCycle({
4598
4703
  attemptId,
4599
4704
  trigger: input.trigger,
4600
- confirmedLargeDiff: input.confirmedLargeDiff === true || hasDurableBranchDeletion,
4601
- confirmationReason: input.confirmationReason ?? (hasDurableBranchDeletion ? "durable project branch deletion" : void 0),
4705
+ confirmedLargeDiff: input.confirmedLargeDiff,
4706
+ confirmationReason: input.confirmationReason,
4602
4707
  resetToCanonical: input.resetToCanonical,
4603
4708
  requiredAncestorHeads: input.requiredAncestorHeads,
4604
- assertStillAdmitted
4605
- });
4606
- } catch (error) {
4607
- const errorMessage = error instanceof Error ? error.message : String(error);
4608
- const hydrationRecoverable = workspaceSyncFailureHydrationIsSafe({
4609
- error,
4610
- resetToCanonical: input.resetToCanonical === true,
4611
- inspectRecoverableHydration: () => workspaceGitHydrationIsRecoverable(workspaceShadowRoot, buildWorkspaceMounts())
4612
- });
4613
- if (hydrationRecoverable) {
4614
- if (error instanceof WorkspaceSyncAdmissionChangedError) {
4615
- process.stderr.write(
4616
- `[r5d-worker] workspace synchronization was cancelled with every mount at a recorded basis; the next cycle converges it: ${errorMessage}
4617
- `
4618
- );
4619
- }
4620
- return await failedWorkspaceSyncResult(attemptId, input.trigger, error);
4621
- }
4622
- const fenced = fenceUnsafeWorkspaceSyncFailure({
4623
- hydrationRecoverable,
4624
- resetToCanonical: input.resetToCanonical === true,
4625
- recoveryAttempts: unsafeWorkspaceSyncRecoveryAttempts,
4626
- maxRecoveryAttempts: WORKSPACE_UNSAFE_SYNC_MAX_RECOVERY_ATTEMPTS,
4627
- hasRetainableWork: activeProcesses.size > 0 || credentialBearingProcessGroups.size > 0 || activePtys.size > 0 || pendingProcessTerminals.size > 0,
4628
- bouncePending: unsafeWorkspaceSyncBouncePending,
4629
- invalidateExecution: () => {
4630
- workspaceConfigured = false;
4631
- advanceWorkerAdmissionGeneration();
4632
- },
4633
- scheduleRecoveryBounce: () => {
4634
- unsafeWorkspaceSyncRecoveryAttempts += 1;
4635
- scheduleUnsafeWorkspaceSyncRecoveryBounce(errorMessage);
4636
- },
4637
- fatalExit: (exitCode) => {
4638
- process.stderr.write(
4639
- `[r5d-worker] workspace synchronization failed without a recoverable hydration basis; exiting for exact recovery after ${unsafeWorkspaceSyncRecoveryAttempts} in-process recovery attempt(s): ${errorMessage}
4640
- `
4641
- );
4642
- process.exit(exitCode);
4643
- },
4644
- exitCode: 1
4709
+ assertStillAdmitted: () => assertWorkspaceSyncStillAdmitted(admission, input.trigger)
4645
4710
  });
4646
- if (fenced === "recovery_pending") {
4647
- process.stderr.write(
4648
- `[r5d-worker] workspace synchronization failed without a recoverable hydration basis while in-process recovery is already pending: ${errorMessage}
4649
- `
4650
- );
4651
- }
4652
- return {
4653
- ...await failedWorkspaceSyncResult(attemptId, input.trigger, error),
4654
- failureReason: "unsafe_hydration",
4655
- recoveryAttempt: unsafeWorkspaceSyncRecoveryAttempts
4656
- };
4657
- }
4658
- });
4711
+ prepareMs = lastWorkspaceSyncMirrorObservationMs;
4712
+ synchronizeMs = Date.now() - startedAt;
4713
+ return cycleResult;
4714
+ });
4715
+ }
4659
4716
  } finally {
4660
4717
  workspaceSyncRequestsInFlight -= 1;
4661
4718
  }
4662
4719
  if (result.outcome !== "failed") unsafeWorkspaceSyncRecoveryAttempts = 0;
4663
- const finishedAt = Date.now();
4664
4720
  result = {
4665
4721
  ...result,
4666
- telemetry: {
4667
- totalMs: finishedAt - requestedAt,
4668
- queueMs: queueEnteredAt - requestedAt,
4669
- // Duration of the pre-outer mirror-head observation phase; near zero
4670
- // on a cycle whose token-gated observation skipped every fetch.
4671
- prepareMs: lastWorkspaceSyncMirrorObservationMs,
4672
- synchronizeMs: finishedAt - syncStartedAt
4673
- }
4722
+ telemetry: { totalMs: Date.now() - requestedAt, queueMs, prepareMs, synchronizeMs }
4674
4723
  };
4675
4724
  if (input.sendResult !== false) sendWorkspaceSyncResult(input.requestId, result);
4676
4725
  sendLifecycleProgress(
@@ -4940,6 +4989,7 @@ async function startWorker(options) {
4940
4989
  );
4941
4990
  }
4942
4991
  workspaceConfigured = false;
4992
+ workspaceProjectionLedger.reset();
4943
4993
  const preserveOnlyBranches = message.projects.flatMap(
4944
4994
  (project) => project.preserveOnlyBranches.map(({ branchId, branchName }) => ({ branchId, projectId: project.projectId, branchName }))
4945
4995
  );
@@ -4949,6 +4999,16 @@ async function startWorker(options) {
4949
4999
  preserveOnlyBranches
4950
5000
  });
4951
5001
  }
5002
+ for (const project of message.projects) {
5003
+ for (const branch of project.branches) {
5004
+ const pending2 = projectWorkspaceState.locallyPendingCreatedBranches.find(
5005
+ (candidate) => candidate.projectId === project.projectId && candidate.branchName === branch.branchName
5006
+ );
5007
+ if (pending2 && pending2.branchId === branch.branchId) {
5008
+ projectWorkspaceState = projectWorkspaceStateStore.clearPendingCreatedBranch(pending2);
5009
+ }
5010
+ }
5011
+ }
4952
5012
  const stillPendingCreatedBranches = new Map(
4953
5013
  projectWorkspaceState.locallyPendingCreatedBranches.map((branch) => [
4954
5014
  pendingCreatedBranchKey(branch.projectId, branch.branchName),
@@ -5390,11 +5450,10 @@ async function startWorker(options) {
5390
5450
  workspaceConfigResetToCanonicalV1: true,
5391
5451
  projectBranchDeletionFastAckV1: true,
5392
5452
  projectMirrorLeaseV1: true,
5393
- projectMirrorRefsTokensV1: true
5453
+ projectMirrorRefsTokensV1: true,
5454
+ projectBranchWorkingTreeModeV1: true
5394
5455
  },
5395
- projectRoot: projectsRoot,
5396
- artifactRoot,
5397
- planRoot
5456
+ root: rootDir
5398
5457
  }
5399
5458
  };
5400
5459
  ws.send(JSON.stringify(hello));
@@ -5429,14 +5488,12 @@ async function startWorker(options) {
5429
5488
  return;
5430
5489
  }
5431
5490
  if (message.type === "project_mirror_refs_tokens") {
5491
+ const advertised = parseProjectMirrorRefsTokensMessage(message);
5432
5492
  advertisedProjectMirrorRefsTokens.clear();
5433
- if (message.tokens && typeof message.tokens === "object") {
5434
- for (const [projectId, token2] of Object.entries(message.tokens)) {
5435
- if (typeof token2 === "string" && /^[0-9a-f]{64}$/.test(token2)) {
5436
- advertisedProjectMirrorRefsTokens.set(projectId, token2);
5437
- }
5438
- }
5439
- }
5493
+ for (const [projectId, token2] of advertised.projectTokens) advertisedProjectMirrorRefsTokens.set(projectId, token2);
5494
+ advertisedProjectMirrorMountTokens.clear();
5495
+ for (const [projectId, tokens] of advertised.mountTokens) advertisedProjectMirrorMountTokens.set(projectId, tokens);
5496
+ advertisedWorkspaceHead = advertised.workspaceHead;
5440
5497
  return;
5441
5498
  }
5442
5499
  if (message.type === "workspace_config") {
@@ -5509,13 +5566,21 @@ async function startWorker(options) {
5509
5566
  });
5510
5567
  return;
5511
5568
  }
5569
+ sendWorkerMessage(ws, {
5570
+ type: "operation_queued",
5571
+ requestId: message.requestId,
5572
+ queuePosition: workspaceSyncSingleFlight.branchMutationQueuePosition()
5573
+ });
5512
5574
  try {
5575
+ if (message.workingTree !== "carry" && message.workingTree !== "clean") {
5576
+ throw new Error('Project branch creation requires workingTree "carry" or "clean"');
5577
+ }
5513
5578
  const pendingBranch = {
5514
5579
  branchId: message.branchId,
5515
5580
  projectId: message.projectId,
5516
5581
  branchName: message.targetBranch
5517
5582
  };
5518
- const created = await workspaceSyncSingleFlight.runMutation(() => {
5583
+ const created = await workspaceSyncSingleFlight.runBranchMutation(() => {
5519
5584
  if (workspaceOperationsAreFenced(deferredWorkspaceConfiguration, activeWorkspaceIncidentId)) {
5520
5585
  throw new Error(
5521
5586
  `Project branch creation was fenced while queued by workspace incident ${deferredWorkspaceConfiguration?.incidentId ?? activeWorkspaceIncidentId}`
@@ -5556,6 +5621,7 @@ async function startWorker(options) {
5556
5621
  primaryBranchName: primaryProjectBranch(project),
5557
5622
  sourceBranchName: message.sourceBranch,
5558
5623
  branchName: message.targetBranch,
5624
+ workingTree: message.workingTree,
5559
5625
  pendingRetry: pendingWasRecorded
5560
5626
  });
5561
5627
  gitBranchCreated = true;
@@ -5612,9 +5678,14 @@ async function startWorker(options) {
5612
5678
  });
5613
5679
  return;
5614
5680
  }
5681
+ sendWorkerMessage(ws, {
5682
+ type: "operation_queued",
5683
+ requestId: message.requestId,
5684
+ queuePosition: workspaceSyncSingleFlight.branchMutationQueuePosition()
5685
+ });
5615
5686
  try {
5616
5687
  const deletionKey = projectBranchKey(message.projectId, message.branchName);
5617
- const deletionNeedsSync = await workspaceSyncSingleFlight.runMutation(() => {
5688
+ const deletionNeedsSync = await workspaceSyncSingleFlight.runBranchMutation(() => {
5618
5689
  if (workspaceOperationsAreFenced(deferredWorkspaceConfiguration, activeWorkspaceIncidentId)) {
5619
5690
  throw new Error(
5620
5691
  `Project branch deletion was fenced while queued by workspace incident ${deferredWorkspaceConfiguration?.incidentId ?? activeWorkspaceIncidentId}`
@@ -5645,6 +5716,9 @@ async function startWorker(options) {
5645
5716
  projectId: message.projectId,
5646
5717
  branchName: message.branchName
5647
5718
  });
5719
+ if (pendingLocalBranch) {
5720
+ projectWorkspaceState = projectWorkspaceStateStore.clearPendingCreatedBranch(pendingLocalBranch);
5721
+ }
5648
5722
  pendingCreatedBranchPublicationNotBefore.delete(pendingCreatedBranchKey(message.projectId, message.branchName));
5649
5723
  if (pendingMirrorDeletes.has(deletionKey)) return true;
5650
5724
  stageProjectBranchDeletion(project, message.branchName, true);
@@ -5922,7 +5996,7 @@ async function startWorker(options) {
5922
5996
  ws,
5923
5997
  message,
5924
5998
  resolvedTarget,
5925
- planRoot,
5999
+ rootDir,
5926
6000
  assertAdmission: assertMessageAdmission,
5927
6001
  ...releaseWorkspaceMutation ? { releaseWorkspaceMutation } : {},
5928
6002
  ...targetMayMutateVisibleWorkspace(message.target) ? {
@@ -6020,8 +6094,7 @@ async function startWorker(options) {
6020
6094
  resolvedTarget,
6021
6095
  baseUrl,
6022
6096
  token,
6023
- artifactRoot,
6024
- planRoot,
6097
+ rootDir,
6025
6098
  setPreparationHeartbeatLease: (active) => {
6026
6099
  sendWorkerMessageFromCurrentSource(ws, {
6027
6100
  type: "heartbeat_lease",
@@ -6090,8 +6163,7 @@ async function startWorker(options) {
6090
6163
  resolvedTarget,
6091
6164
  baseUrl,
6092
6165
  token,
6093
- artifactRoot,
6094
- planRoot,
6166
+ rootDir,
6095
6167
  setPreparationHeartbeatLease: (active) => {
6096
6168
  sendWorkerMessageFromCurrentSource(ws, {
6097
6169
  type: "heartbeat_lease",
@@ -6173,8 +6245,7 @@ async function startWorker(options) {
6173
6245
  resolvedTarget,
6174
6246
  baseUrl,
6175
6247
  token,
6176
- artifactRoot,
6177
- planRoot,
6248
+ rootDir,
6178
6249
  assertAdmission: assertMessageAdmission
6179
6250
  });
6180
6251
  });