@ricsam/r5d-worker 0.0.77 → 0.0.79

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 (58) hide show
  1. package/README.md +6 -0
  2. package/dist/cjs/main.cjs +1562 -335
  3. package/dist/cjs/managed-paths.cjs +101 -1
  4. package/dist/cjs/package.json +1 -1
  5. package/dist/cjs/project-workspace-state.cjs +184 -5
  6. package/dist/cjs/project-worktrees.cjs +641 -56
  7. package/dist/cjs/registry-auth.cjs +310 -0
  8. package/dist/cjs/repository-transition-policy.cjs +49 -0
  9. package/dist/cjs/supervisor.cjs +62 -11
  10. package/dist/cjs/working-tree-mirror.cjs +191 -34
  11. package/dist/cjs/workspace-automatic-sync-policy.cjs +69 -0
  12. package/dist/cjs/workspace-branch-incarnation-policy.cjs +37 -0
  13. package/dist/cjs/workspace-command-sync-policy.cjs +18 -0
  14. package/dist/cjs/workspace-git-sync.cjs +1028 -54
  15. package/dist/cjs/workspace-mount-boundary.cjs +71 -0
  16. package/dist/cjs/workspace-path-move.cjs +195 -0
  17. package/dist/cjs/workspace-preserve-only-policy.cjs +36 -0
  18. package/dist/cjs/workspace-project-config-policy.cjs +46 -0
  19. package/dist/cjs/workspace-publication-evidence.cjs +46 -0
  20. package/dist/mjs/main.mjs +1596 -341
  21. package/dist/mjs/managed-paths.mjs +100 -1
  22. package/dist/mjs/package.json +1 -1
  23. package/dist/mjs/project-workspace-state.mjs +181 -5
  24. package/dist/mjs/project-worktrees.mjs +632 -55
  25. package/dist/mjs/registry-auth.mjs +269 -0
  26. package/dist/mjs/repository-transition-policy.mjs +22 -0
  27. package/dist/mjs/supervisor.mjs +61 -11
  28. package/dist/mjs/working-tree-mirror.mjs +190 -34
  29. package/dist/mjs/workspace-automatic-sync-policy.mjs +40 -0
  30. package/dist/mjs/workspace-branch-incarnation-policy.mjs +13 -0
  31. package/dist/mjs/workspace-command-sync-policy.mjs +17 -0
  32. package/dist/mjs/workspace-git-sync.mjs +1023 -54
  33. package/dist/mjs/workspace-mount-boundary.mjs +37 -0
  34. package/dist/mjs/workspace-path-move.mjs +160 -0
  35. package/dist/mjs/workspace-preserve-only-policy.mjs +11 -0
  36. package/dist/mjs/workspace-project-config-policy.mjs +21 -0
  37. package/dist/mjs/workspace-publication-evidence.mjs +21 -0
  38. package/dist/types/credential-authority-lock-fixture.d.ts +1 -0
  39. package/dist/types/main.d.ts +175 -1
  40. package/dist/types/managed-paths.d.ts +11 -0
  41. package/dist/types/project-workspace-state.d.ts +45 -1
  42. package/dist/types/project-worktrees.d.ts +119 -2
  43. package/dist/types/registry-auth.d.ts +47 -0
  44. package/dist/types/repository-transition-policy.d.ts +26 -0
  45. package/dist/types/supervisor-daemonized-fixture.d.ts +1 -0
  46. package/dist/types/supervisor-signal-fixture.d.ts +1 -0
  47. package/dist/types/supervisor.d.ts +6 -4
  48. package/dist/types/working-tree-mirror.d.ts +7 -0
  49. package/dist/types/workspace-automatic-sync-policy.d.ts +37 -0
  50. package/dist/types/workspace-branch-incarnation-policy.d.ts +14 -0
  51. package/dist/types/workspace-command-sync-policy.d.ts +9 -0
  52. package/dist/types/workspace-git-sync.d.ts +33 -3
  53. package/dist/types/workspace-mount-boundary.d.ts +10 -0
  54. package/dist/types/workspace-path-move.d.ts +21 -0
  55. package/dist/types/workspace-preserve-only-policy.d.ts +15 -0
  56. package/dist/types/workspace-project-config-policy.d.ts +34 -0
  57. package/dist/types/workspace-publication-evidence.d.ts +17 -0
  58. package/package.json +1 -1
package/dist/mjs/main.mjs CHANGED
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { createHash } from "node:crypto";
5
5
  import os, { hostname } from "node:os";
6
6
  import { spawn as spawnChildProcess } from "node:child_process";
7
+ import { Database } from "bun:sqlite";
7
8
  import { installCliUpdate, readInstalledCliVersion } from "./cli-update.mjs";
8
9
  import { gitTransportSecurityArgs, workerGitProcessEnvironment } from "./git-process-environment.mjs";
9
10
  import {
@@ -14,40 +15,79 @@ import {
14
15
  import { terminateProcessTree } from "./process-tree.mjs";
15
16
  import { createPtyOutputCoalescer } from "./pty-output-coalescer.mjs";
16
17
  import { openWorkerPortForwardRelay } from "./port-forward-client.mjs";
18
+ import {
19
+ configureGitHubRegistryAuthFiles,
20
+ planGitHubRegistryAuthFiles,
21
+ preparePrivateAuthFileGeneration,
22
+ registryAuthHasAppManagedGitHubCredential,
23
+ RegistryAuthConfigurationError
24
+ } from "./registry-auth.mjs";
17
25
  import { applyWorkspaceIncidentUpdate } from "./workspace-incident-state.mjs";
18
- import { acquireWorkspaceCommandMutation, runWorkspaceCommand } from "./workspace-command-sync-policy.mjs";
26
+ import {
27
+ hasActiveVisibleProjectsWorkspaceTarget,
28
+ PENDING_CREATED_BRANCH_AUTOMATIC_SYNC_GRACE_MS,
29
+ pendingCreatedBranchAutomaticSyncDeferral,
30
+ pendingCreatedBranchKey,
31
+ projectBranchHasActiveWorkspaceTarget
32
+ } from "./workspace-automatic-sync-policy.mjs";
33
+ import {
34
+ acquireWorkspaceCommandMutation,
35
+ reserveWorkspaceCommandAfterCurrentSync,
36
+ runWorkspaceCommand
37
+ } from "./workspace-command-sync-policy.mjs";
19
38
  import { WorkspaceMutationGate } from "./workspace-mutation-gate.mjs";
39
+ import { checkoutPathMovePublicationComplete, preserveProjectCheckoutPathMove } from "./workspace-path-move.mjs";
40
+ import { busyProjectConfigurationChangeIds, deferredProjectConfigurationPendingBranches } from "./workspace-project-config-policy.mjs";
41
+ import { creatorLocalProjectBranchIsAuthorized, workerProjectBranchDisposition } from "./workspace-preserve-only-policy.mjs";
42
+ import { assertProjectBranchDeletionIncarnation } from "./workspace-branch-incarnation-policy.mjs";
43
+ import { activeProjectBranchPublicationEvidence } from "./workspace-publication-evidence.mjs";
44
+ import {
45
+ assertRepositoryExecutionEnabled,
46
+ assertRepositoryTransitionState,
47
+ enabledRepositoryTransitionRequiresReseed,
48
+ repositoryMirrorWritesAllowed
49
+ } from "./repository-transition-policy.mjs";
20
50
  import {
21
51
  isRetryableWorkerServerStatus,
22
52
  superviseWorkerRuntime,
53
+ WORKER_CREDENTIAL_RESTART_EXIT_CODE,
23
54
  WORKER_RECONNECT_DELAY_MS,
24
55
  WORKER_RECONNECT_EXIT_CODE,
25
56
  WORKER_RELOAD_EXIT_CODE,
26
57
  WORKER_RUNTIME_ENV
27
58
  } from "./supervisor.mjs";
28
- import { managedProjectRoot, validateManagedBranchName } from "./managed-paths.mjs";
59
+ import { assertDisjointManagedRoots, managedProjectRoot, validateManagedBranchName } from "./managed-paths.mjs";
29
60
  import {
30
- cleanupStaleProjectWorktreeSnapshots,
31
- createLinkedProjectBranch,
61
+ applyObservedProjectMirrorHeads,
62
+ assertProjectMirrorHeadsPushed,
63
+ recoverStaleProjectWorktreeSnapshots,
64
+ createOrRetryLinkedProjectBranch,
32
65
  deleteLinkedProjectBranch,
33
66
  deleteProjectMirrorBranch,
34
67
  ensureProjectWorktrees,
35
- fastForwardProjectHeadsFromMirror,
68
+ observeProjectMirrorHeads,
69
+ projectBranchMayExistAfterCreateFailure,
70
+ projectOriginBranchName,
36
71
  projectWorktreeConfigurationFingerprint,
37
72
  projectWorktreeOperationInProgress,
38
73
  pushProjectMirrorHeads,
39
74
  removeProjectWorktrees
40
75
  } from "./project-worktrees.mjs";
41
76
  import {
77
+ ProjectWorkspacePendingBranchPathChangeError,
42
78
  ProjectWorkspaceStateStore,
43
79
  pruneAuthoritativelyDesiredBranchDeletions
44
80
  } from "./project-workspace-state.mjs";
45
81
  import {
46
82
  ensureWorkspaceGitClone,
47
83
  hydrateWorkspaceGitMounts,
84
+ recoverWorkspaceGitHydration,
48
85
  resetWorkspaceGit,
49
- synchronizeWorkspaceGit
86
+ synchronizeWorkspaceGit,
87
+ workspaceGitHydrationIsCurrent
50
88
  } from "./workspace-git-sync.mjs";
89
+ class ProjectWorkspaceConfigurationDeferredError extends Error {
90
+ }
51
91
  class WorkerServerUnavailableError extends Error {
52
92
  name = "WorkerServerUnavailableError";
53
93
  }
@@ -60,12 +100,19 @@ const MAX_LINE_LENGTH = 2e3;
60
100
  const WORKSPACE_GIT_QUIET_MS = 5e3;
61
101
  const WORKSPACE_GIT_PERIODIC_MS = 6e4;
62
102
  const activeProcesses = /* @__PURE__ */ new Map();
103
+ const credentialBearingProcessGroups = /* @__PURE__ */ new Map();
104
+ const credentialBearingProcessGroupTargets = /* @__PURE__ */ new Map();
105
+ const workspaceSyncPriorityProcessTargets = /* @__PURE__ */ new Map();
106
+ const workspaceSyncPriorityOperationTargets = /* @__PURE__ */ new Map();
63
107
  const pendingProcessTerminals = /* @__PURE__ */ new Map();
64
108
  const cancelledProcessRuns = /* @__PURE__ */ new Set();
65
109
  const activePtys = /* @__PURE__ */ new Map();
110
+ const workspaceSyncPriorityPtyTargets = /* @__PURE__ */ new Map();
66
111
  let currentWorkerSocket = null;
112
+ let workerAdmissionGeneration = 0;
67
113
  const workspaceMutationGate = new WorkspaceMutationGate();
68
114
  let workspaceSyncQueue = Promise.resolve();
115
+ let startupProjectSnapshotRecoveryCompleted = false;
69
116
  const workspaceSyncSingleFlight = {
70
117
  runExclusive(operation) {
71
118
  const queued = workspaceMutationGate.runSync(operation);
@@ -86,7 +133,29 @@ const workspaceSyncSingleFlight = {
86
133
  }
87
134
  };
88
135
  let githubCredential = null;
136
+ let configuredCredentialGenerationFingerprint = null;
137
+ let pendingCredentialGenerationIntentAtProcessStart = null;
138
+ let bootstrapCredentialGenerationFingerprintAtProcessStart = null;
139
+ let bootSessionIdentifierAtProcessStart = null;
140
+ let machineIdentifierAtProcessStart = null;
141
+ let systemdInvocationIdentifierAtProcessStart = null;
142
+ let initialCredentialBootstrapProvenEmpty = false;
89
143
  let visibleGitIdentity = null;
144
+ class StaleWorkerAdmissionError extends Error {
145
+ constructor() {
146
+ super("Worker command admission was invalidated before child creation");
147
+ this.name = "StaleWorkerAdmissionError";
148
+ }
149
+ }
150
+ function advanceWorkerAdmissionGeneration() {
151
+ workerAdmissionGeneration += 1;
152
+ return workerAdmissionGeneration;
153
+ }
154
+ function assertWorkerChildAdmission(input) {
155
+ if (input.capturedGeneration !== input.currentGeneration || !input.sourceSocketIsCurrent || !input.sourceSocketOpen || !input.workspaceConfigured || !input.runtimeHealthy) {
156
+ throw new StaleWorkerAdmissionError();
157
+ }
158
+ }
90
159
  function defaultConfigPath() {
91
160
  return path.join(os.homedir(), ".config", "r5d", "r5dctl", "config.json");
92
161
  }
@@ -788,6 +857,183 @@ function readOnlyCredentialStoreHelper(storePath) {
788
857
  function credentialStoreDirectory() {
789
858
  return path.join(os.homedir(), ".r5d", "git-credentials");
790
859
  }
860
+ function credentialGenerationReceiptPath(directoryPath = credentialStoreDirectory()) {
861
+ return path.join(directoryPath, ".auth-generation");
862
+ }
863
+ function pendingCredentialGenerationPath(directoryPath = credentialStoreDirectory()) {
864
+ return path.join(directoryPath, ".auth-generation-pending");
865
+ }
866
+ function bootstrapCredentialGenerationPath(directoryPath = credentialStoreDirectory()) {
867
+ return path.join(directoryPath, ".auth-generation-bootstrap");
868
+ }
869
+ const BOOT_SESSION_IDENTIFIER_PATTERN = /^(?:linux:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|darwin:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/;
870
+ const MACHINE_IDENTIFIER_PATTERN = /^(?:linux:[0-9a-f]{32}|darwin:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|win32:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/;
871
+ const SYSTEMD_INVOCATION_IDENTIFIER_PATTERN = /^[0-9a-f]{32}$/;
872
+ function exactObjectKeys(value, expected) {
873
+ const actual = Object.keys(value).sort();
874
+ const expectedKeys = [...expected].sort();
875
+ return actual.length === expectedKeys.length && actual.every((key, index) => key === expectedKeys[index]);
876
+ }
877
+ function pendingCredentialGenerationIntentContent(intent) {
878
+ return `${JSON.stringify({ version: 1, fingerprint: intent.fingerprint, boundary: intent.boundary })}
879
+ `;
880
+ }
881
+ function readPendingCredentialGenerationIntent(intentPath = pendingCredentialGenerationPath()) {
882
+ const stat = lstatIfExists(intentPath);
883
+ if (!stat) return null;
884
+ if (stat.isSymbolicLink() || !stat.isFile()) {
885
+ throw new RegistryAuthConfigurationError(new Error(`Pending credential generation intent must be a regular file: ${intentPath}`));
886
+ }
887
+ try {
888
+ const parsed = JSON.parse(fs.readFileSync(intentPath, "utf8"));
889
+ const boundary = parsed.boundary;
890
+ if (!parsed || Array.isArray(parsed) || !exactObjectKeys(parsed, ["boundary", "fingerprint", "version"]) || parsed.version !== 1 || typeof parsed.fingerprint !== "string" || !/^[0-9a-f]{64}$/.test(parsed.fingerprint) || !boundary || typeof boundary !== "object" || Array.isArray(boundary)) {
891
+ throw new Error("invalid intent fields");
892
+ }
893
+ const boundaryRecord = boundary;
894
+ const machineId = boundaryRecord.machineId;
895
+ if (!(machineId === null || typeof machineId === "string" && MACHINE_IDENTIFIER_PATTERN.test(machineId))) {
896
+ throw new Error("invalid machine identifier");
897
+ }
898
+ let parsedBoundary;
899
+ if (boundaryRecord.kind === "systemd" && exactObjectKeys(boundaryRecord, ["invocationId", "kind", "machineId"]) && typeof boundaryRecord.invocationId === "string" && SYSTEMD_INVOCATION_IDENTIFIER_PATTERN.test(boundaryRecord.invocationId)) {
900
+ parsedBoundary = {
901
+ kind: "systemd",
902
+ machineId,
903
+ invocationId: boundaryRecord.invocationId
904
+ };
905
+ } else if (boundaryRecord.kind === "boot" && exactObjectKeys(boundaryRecord, ["bootSessionId", "kind", "machineId"]) && (boundaryRecord.bootSessionId === null || typeof boundaryRecord.bootSessionId === "string" && BOOT_SESSION_IDENTIFIER_PATTERN.test(boundaryRecord.bootSessionId))) {
906
+ parsedBoundary = {
907
+ kind: "boot",
908
+ machineId,
909
+ bootSessionId: boundaryRecord.bootSessionId
910
+ };
911
+ } else {
912
+ throw new Error("invalid credential boundary");
913
+ }
914
+ return { fingerprint: parsed.fingerprint, boundary: parsedBoundary };
915
+ } catch (error) {
916
+ throw new RegistryAuthConfigurationError(
917
+ new Error(`Pending credential generation intent is malformed: ${intentPath}`, { cause: error })
918
+ );
919
+ }
920
+ }
921
+ function parseBootSessionIdentifier(probe) {
922
+ const value = probe.kernelValue.trim();
923
+ if (probe.platform === "linux") {
924
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value) ? `linux:${value.toLowerCase()}` : null;
925
+ }
926
+ if (probe.platform === "darwin") {
927
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value) ? `darwin:${value.toLowerCase()}` : null;
928
+ }
929
+ return null;
930
+ }
931
+ function readBootSessionIdentifier(probe) {
932
+ if (probe) return parseBootSessionIdentifier(probe);
933
+ try {
934
+ if (process.platform === "linux") {
935
+ return parseBootSessionIdentifier({
936
+ platform: process.platform,
937
+ kernelValue: fs.readFileSync("/proc/sys/kernel/random/boot_id", "utf8")
938
+ });
939
+ }
940
+ const command = process.platform === "darwin" ? ["/usr/sbin/sysctl", "-n", "kern.bootsessionuuid"] : null;
941
+ if (!command) return null;
942
+ const result = Bun.spawnSync(command, {
943
+ stdout: "pipe",
944
+ stderr: "pipe",
945
+ env: workerGitProcessEnvironment(),
946
+ timeout: 2e3
947
+ });
948
+ if (result.exitCode !== 0) return null;
949
+ return parseBootSessionIdentifier({ platform: process.platform, kernelValue: result.stdout.toString() });
950
+ } catch {
951
+ return null;
952
+ }
953
+ }
954
+ function parseMachineIdentifier(probe) {
955
+ const value = probe.kernelValue.trim();
956
+ if (probe.platform === "linux") {
957
+ return /^[0-9a-f]{32}$/i.test(value) ? `linux:${value.toLowerCase()}` : null;
958
+ }
959
+ const uuid = probe.platform === "darwin" ? /["']?IOPlatformUUID["']?\s*=\s*["']([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})["']/i.exec(value)?.[1] : probe.platform === "win32" ? /^\{?([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\}?$/i.exec(value)?.[1] : null;
960
+ return uuid ? `${probe.platform}:${uuid.toLowerCase()}` : null;
961
+ }
962
+ function readMachineIdentifier(probe) {
963
+ if (probe) return parseMachineIdentifier(probe);
964
+ try {
965
+ if (process.platform === "linux") {
966
+ return parseMachineIdentifier({ platform: process.platform, kernelValue: fs.readFileSync("/etc/machine-id", "utf8") });
967
+ }
968
+ const command = process.platform === "darwin" ? ["/usr/sbin/ioreg", "-rd1", "-c", "IOPlatformExpertDevice"] : null;
969
+ if (!command) return null;
970
+ const result = Bun.spawnSync(command, {
971
+ stdout: "pipe",
972
+ stderr: "pipe",
973
+ env: workerGitProcessEnvironment(),
974
+ timeout: 2e3
975
+ });
976
+ if (result.exitCode !== 0) return null;
977
+ return parseMachineIdentifier({ platform: process.platform, kernelValue: result.stdout.toString() });
978
+ } catch {
979
+ return null;
980
+ }
981
+ }
982
+ function credentialAuthorityLockPath(rootPath = path.join(os.homedir(), ".r5d")) {
983
+ return path.join(rootPath, ".credential-authority.sqlite");
984
+ }
985
+ function acquireCredentialAuthorityLock(input) {
986
+ const rootPath = path.resolve(input.rootPath ?? path.join(os.homedir(), ".r5d"));
987
+ const rootStat = lstatIfExists(rootPath);
988
+ if (rootStat) {
989
+ if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
990
+ throw new Error(`Worker credential authority root must be a directory: ${rootPath}`);
991
+ }
992
+ } else {
993
+ fs.mkdirSync(rootPath, { recursive: true, mode: 448 });
994
+ }
995
+ fs.chmodSync(rootPath, 448);
996
+ const lockPath = credentialAuthorityLockPath(rootPath);
997
+ const existing = lstatIfExists(lockPath);
998
+ if (existing && (existing.isSymbolicLink() || !existing.isFile())) {
999
+ throw new Error(`Worker credential authority database must be a regular file: ${lockPath}`);
1000
+ }
1001
+ const database = new Database(lockPath, { create: true, strict: true });
1002
+ try {
1003
+ database.exec("PRAGMA busy_timeout = 0; PRAGMA journal_mode = DELETE");
1004
+ database.exec("CREATE TABLE IF NOT EXISTS authority_metadata (singleton INTEGER PRIMARY KEY CHECK (singleton = 1), machine_id TEXT)");
1005
+ database.exec("BEGIN IMMEDIATE");
1006
+ try {
1007
+ const row = database.query("SELECT machine_id AS machineId FROM authority_metadata WHERE singleton = 1").get();
1008
+ if (row) {
1009
+ if (row.machineId === null || input.machineId === null) {
1010
+ if (row.machineId !== input.machineId) {
1011
+ throw new Error("Cannot prove that the worker credential authority database belongs to this machine");
1012
+ }
1013
+ } else if (row.machineId !== input.machineId) {
1014
+ throw new Error("Worker credential authority home belongs to a different machine; refusing to move or share credential state");
1015
+ }
1016
+ } else {
1017
+ database.query("INSERT INTO authority_metadata (singleton, machine_id) VALUES (1, ?)").run(input.machineId);
1018
+ }
1019
+ database.exec("COMMIT");
1020
+ } catch (error) {
1021
+ database.exec("ROLLBACK");
1022
+ throw error;
1023
+ }
1024
+ database.exec("BEGIN EXCLUSIVE");
1025
+ return { lockPath, database };
1026
+ } catch (error) {
1027
+ database.close(false);
1028
+ if (error instanceof Error && /database is locked|SQLITE_BUSY/i.test(error.message)) {
1029
+ throw new Error("Another r5d-worker credential authority is already running under this OS account", { cause: error });
1030
+ }
1031
+ throw error;
1032
+ }
1033
+ }
1034
+ function releaseCredentialAuthorityLockHandle(handle) {
1035
+ handle.database.close(false);
1036
+ }
791
1037
  function legacySharedCredentialStorePath() {
792
1038
  return path.join(os.homedir(), ".r5d", "github-credentials");
793
1039
  }
@@ -818,9 +1064,24 @@ function ensurePrivateCredentialStoreDirectory(directoryPath) {
818
1064
  fs.chmodSync(directoryPath, 448);
819
1065
  }
820
1066
  function removeLegacySharedCredentialStore(storePath = legacySharedCredentialStorePath()) {
821
- fs.rmSync(storePath, { force: true });
1067
+ const prepared = preparePrivateAuthFileGeneration([{ filePath: storePath, content: null, allowSymlinkRemoval: true }]);
1068
+ prepared.commit();
822
1069
  }
823
1070
  let credentialStoreDirectoryInitialized = false;
1071
+ let credentialAuthorityLockHandle = null;
1072
+ function releaseCredentialAuthorityLock() {
1073
+ const handle = credentialAuthorityLockHandle;
1074
+ credentialAuthorityLockHandle = null;
1075
+ if (!handle) return;
1076
+ try {
1077
+ releaseCredentialAuthorityLockHandle(handle);
1078
+ } catch (error) {
1079
+ process.stderr.write(
1080
+ `[r5d-worker] could not release credential authority lock: ${error instanceof Error ? error.message : String(error)}
1081
+ `
1082
+ );
1083
+ }
1084
+ }
824
1085
  function resetCredentialStoreDirectory(directoryPath = credentialStoreDirectory()) {
825
1086
  const stat = lstatIfExists(directoryPath);
826
1087
  if (stat) {
@@ -831,11 +1092,73 @@ function resetCredentialStoreDirectory(directoryPath = credentialStoreDirectory(
831
1092
  }
832
1093
  function initializeCredentialStoreDirectoryOnce() {
833
1094
  if (credentialStoreDirectoryInitialized) return;
834
- removeLegacySharedCredentialStore();
835
- resetCredentialStoreDirectory();
1095
+ systemdInvocationIdentifierAtProcessStart = SYSTEMD_INVOCATION_IDENTIFIER_PATTERN.test(process.env.INVOCATION_ID ?? "") ? process.env.INVOCATION_ID.toLowerCase() : null;
1096
+ bootSessionIdentifierAtProcessStart = readBootSessionIdentifier();
1097
+ machineIdentifierAtProcessStart = readMachineIdentifier();
1098
+ credentialAuthorityLockHandle = acquireCredentialAuthorityLock({
1099
+ machineId: machineIdentifierAtProcessStart
1100
+ });
1101
+ process.once("exit", releaseCredentialAuthorityLock);
1102
+ initialCredentialBootstrapProvenEmpty = noPriorCredentialArtifactsAtProcessStart();
1103
+ ensurePrivateCredentialStoreDirectory(credentialStoreDirectory());
1104
+ configuredCredentialGenerationFingerprint = readCredentialGenerationReceipt();
1105
+ pendingCredentialGenerationIntentAtProcessStart = readPendingCredentialGenerationIntent();
1106
+ bootstrapCredentialGenerationFingerprintAtProcessStart = readCredentialGenerationReceipt(bootstrapCredentialGenerationPath());
836
1107
  credentialStoreDirectoryInitialized = true;
837
1108
  }
1109
+ function noPriorCredentialArtifactsAtProcessStart() {
1110
+ if (lstatIfExists(credentialStoreDirectory()) || lstatIfExists(legacySharedCredentialStorePath())) return false;
1111
+ try {
1112
+ return ![dockerConfigPath(), containersAuthPath()].some(registryAuthHasAppManagedGitHubCredential);
1113
+ } catch {
1114
+ return false;
1115
+ }
1116
+ }
1117
+ function readCredentialGenerationReceipt(receiptPath = credentialGenerationReceiptPath()) {
1118
+ const stat = lstatIfExists(receiptPath);
1119
+ if (!stat) return null;
1120
+ if (stat.isSymbolicLink() || !stat.isFile()) {
1121
+ throw new RegistryAuthConfigurationError(new Error(`Credential generation receipt must be a regular file: ${receiptPath}`));
1122
+ }
1123
+ const content = fs.readFileSync(receiptPath, "utf8");
1124
+ const match = /^([0-9a-f]{64})\n$/.exec(content);
1125
+ if (!match) {
1126
+ throw new RegistryAuthConfigurationError(new Error(`Credential generation receipt is malformed: ${receiptPath}`));
1127
+ }
1128
+ return match[1];
1129
+ }
838
1130
  const APP_CREDENTIAL_STORE_FILE = /^(?:project|workspace)-[0-9a-f]{64}\.store$/;
1131
+ function planCredentialStoreGeneration(desiredFiles, directoryPath = credentialStoreDirectory()) {
1132
+ ensurePrivateCredentialStoreDirectory(directoryPath);
1133
+ const resolvedDirectory = path.resolve(directoryPath);
1134
+ const updates = /* @__PURE__ */ new Map();
1135
+ const symlinkRemovals = /* @__PURE__ */ new Set();
1136
+ for (const [storePath, content] of desiredFiles) {
1137
+ const resolvedStorePath = path.resolve(storePath);
1138
+ if (path.dirname(resolvedStorePath) !== resolvedDirectory || !APP_CREDENTIAL_STORE_FILE.test(path.basename(resolvedStorePath))) {
1139
+ throw new Error("Invalid app-owned Git credential-store path");
1140
+ }
1141
+ updates.set(resolvedStorePath, content);
1142
+ }
1143
+ for (const name of fs.readdirSync(directoryPath)) {
1144
+ if (!APP_CREDENTIAL_STORE_FILE.test(name)) continue;
1145
+ const storePath = path.join(resolvedDirectory, name);
1146
+ const stat = fs.lstatSync(storePath);
1147
+ if (stat.isSymbolicLink()) {
1148
+ if (updates.has(storePath)) throw new Error(`Required Git credential store must not be a symlink: ${storePath}`);
1149
+ updates.set(storePath, null);
1150
+ symlinkRemovals.add(storePath);
1151
+ continue;
1152
+ }
1153
+ if (!stat.isFile()) throw new Error(`Git credential store must be a regular file: ${storePath}`);
1154
+ if (!updates.has(storePath)) updates.set(storePath, null);
1155
+ }
1156
+ return [...updates].map(([filePath, content]) => ({
1157
+ filePath,
1158
+ content,
1159
+ ...symlinkRemovals.has(filePath) ? { allowSymlinkRemoval: true } : {}
1160
+ }));
1161
+ }
839
1162
  function pruneCredentialStoreFiles(requiredStorePaths, directoryPath = credentialStoreDirectory()) {
840
1163
  ensurePrivateCredentialStoreDirectory(directoryPath);
841
1164
  const resolvedDirectory = path.resolve(directoryPath);
@@ -853,9 +1176,10 @@ function pruneCredentialStoreFiles(requiredStorePaths, directoryPath = credentia
853
1176
  const storePath = path.join(directoryPath, name);
854
1177
  const stat = fs.lstatSync(storePath);
855
1178
  if (!stat.isFile() && !stat.isSymbolicLink()) continue;
856
- fs.unlinkSync(storePath);
857
1179
  removed.push(storePath);
858
1180
  }
1181
+ const prepared = preparePrivateAuthFileGeneration(removed.map((filePath) => ({ filePath, content: null, allowSymlinkRemoval: true })));
1182
+ prepared.commit();
859
1183
  return removed;
860
1184
  }
861
1185
  function removeProjectCredentialStore(projectId) {
@@ -863,7 +1187,8 @@ function removeProjectCredentialStore(projectId) {
863
1187
  const stat = lstatIfExists(storePath);
864
1188
  if (!stat) return;
865
1189
  if (!stat.isFile() && !stat.isSymbolicLink()) throw new Error("Git credential store must be a regular file");
866
- fs.unlinkSync(storePath);
1190
+ const prepared = preparePrivateAuthFileGeneration([{ filePath: storePath, content: null, allowSymlinkRemoval: true }]);
1191
+ prepared.commit();
867
1192
  }
868
1193
  function decodeGitAuthHeader(authHeader) {
869
1194
  const match = /^Authorization:\s*Basic\s+(.+)$/i.exec(authHeader.trim());
@@ -883,16 +1208,10 @@ function credentialUsernameForAuthHeader(authHeader) {
883
1208
  return credentials.username;
884
1209
  }
885
1210
  function replaceCredentialStoreFile(storePath, content) {
886
- const temporaryPath = path.join(path.dirname(storePath), `.${path.basename(storePath)}.${process.pid}.${crypto.randomUUID()}.tmp`);
887
- try {
888
- fs.writeFileSync(temporaryPath, content, { flag: "wx", mode: 384 });
889
- fs.chmodSync(temporaryPath, 384);
890
- fs.renameSync(temporaryPath, storePath);
891
- } finally {
892
- fs.rmSync(temporaryPath, { force: true });
893
- }
1211
+ const prepared = preparePrivateAuthFileGeneration([{ filePath: storePath, content }]);
1212
+ prepared.commit();
894
1213
  }
895
- function writeCredentialStoreEntries(remotes, storePath) {
1214
+ function credentialStoreContent(remotes) {
896
1215
  const authoritativeEntries = /* @__PURE__ */ new Map();
897
1216
  for (const input of remotes) {
898
1217
  let remote;
@@ -924,23 +1243,179 @@ function writeCredentialStoreEntries(remotes, storePath) {
924
1243
  }
925
1244
  authoritativeEntries.set(hostKey, replacement);
926
1245
  }
927
- const storeStat = lstatIfExists(storePath);
928
- if (storeStat) {
929
- if (storeStat.isSymbolicLink() || !storeStat.isFile()) throw new Error("Git credential store must be a regular file");
930
- }
931
1246
  const replacements = [...authoritativeEntries.values()].filter((entry) => entry !== null);
932
- if (replacements.length === 0) {
933
- if (storeStat) fs.unlinkSync(storePath);
934
- return null;
1247
+ return replacements.length > 0 ? `${replacements.join("\n")}
1248
+ ` : null;
1249
+ }
1250
+ function writeCredentialStoreEntries(remotes, storePath) {
1251
+ const content = credentialStoreContent(remotes);
1252
+ const prepared = preparePrivateAuthFileGeneration([{ filePath: storePath, content }]);
1253
+ prepared.commit();
1254
+ return content === null ? null : { hasCredentials: true, storePath };
1255
+ }
1256
+ function credentialHelperForRemotes(remotes, storePath, desiredFiles) {
1257
+ if (!desiredFiles) {
1258
+ const credentialStore = writeCredentialStoreEntries(remotes, storePath);
1259
+ return credentialStore?.hasCredentials ? readOnlyCredentialStoreHelper(credentialStore.storePath) : null;
1260
+ }
1261
+ const resolvedStorePath = path.resolve(storePath);
1262
+ const content = credentialStoreContent(remotes);
1263
+ const previous = desiredFiles.get(resolvedStorePath);
1264
+ if (desiredFiles.has(resolvedStorePath) && previous !== content) {
1265
+ throw new Error(`Conflicting desired Git credentials for ${resolvedStorePath}`);
1266
+ }
1267
+ desiredFiles.set(resolvedStorePath, content);
1268
+ return content === null ? null : readOnlyCredentialStoreHelper(resolvedStorePath);
1269
+ }
1270
+ function authenticatedRemoteCredentialSecret(remoteUrl, authHeader) {
1271
+ if (authHeader == null) return null;
1272
+ let remote;
1273
+ try {
1274
+ remote = new URL(remoteUrl);
1275
+ } catch {
1276
+ throw new Error("Invalid authenticated Git remote URL");
935
1277
  }
936
- ensurePrivateCredentialStoreDirectory(path.dirname(storePath));
937
- replaceCredentialStoreFile(storePath, `${replacements.join("\n")}
938
- `);
939
- return { hasCredentials: true, storePath };
1278
+ if (remote.protocol !== "https:" && remote.protocol !== "http:") {
1279
+ throw new Error("Unsupported authenticated Git remote protocol");
1280
+ }
1281
+ if (remote.username || remote.password) throw new Error("Git credentials must not be embedded in a remote URL");
1282
+ const credentials = decodeGitAuthHeader(authHeader);
1283
+ if (!credentials) throw new Error("Unsupported Git authorization header");
1284
+ return credentials.password;
1285
+ }
1286
+ function credentialMaterialCommitment(kind, values) {
1287
+ return createHash("sha256").update(JSON.stringify([`r5d-credential-${kind}-v1`, ...values])).digest("hex");
1288
+ }
1289
+ function credentialGenerationFingerprint(config) {
1290
+ const workerApiUrl = new URL(config.workerBaseUrl);
1291
+ if (workerApiUrl.protocol !== "https:" && workerApiUrl.protocol !== "http:") {
1292
+ throw new Error("Unsupported worker API protocol");
1293
+ }
1294
+ const workerCredentials = decodeGitAuthHeader(config.workerAuthHeader);
1295
+ if (!workerCredentials) throw new Error("Unsupported worker authorization header");
1296
+ const materialCommitments = /* @__PURE__ */ new Set();
1297
+ const addSecret = (secret) => {
1298
+ materialCommitments.add(`secret:${credentialMaterialCommitment("secret", [secret])}`);
1299
+ };
1300
+ addSecret(workerCredentials.password);
1301
+ const workspaceSecret = authenticatedRemoteCredentialSecret(config.workspaceRemoteUrl, config.workerAuthHeader);
1302
+ if (workspaceSecret) addSecret(workspaceSecret);
1303
+ for (const project of config.projects) {
1304
+ if (!project.repoHttpUrl) continue;
1305
+ const secret = authenticatedRemoteCredentialSecret(project.repoHttpUrl, project.repoAuthHeader);
1306
+ if (secret) addSecret(secret);
1307
+ }
1308
+ if (config.githubCredential) {
1309
+ addSecret(config.githubCredential.token);
1310
+ materialCommitments.add(
1311
+ `github_authority:${credentialMaterialCommitment("github_authority", [
1312
+ config.githubCredential.token,
1313
+ ...[...config.githubCredential.missingScopes].sort()
1314
+ ])}`
1315
+ );
1316
+ }
1317
+ return createHash("sha256").update(JSON.stringify([...materialCommitments].sort())).digest("hex");
1318
+ }
1319
+ function credentialGenerationTransitionPhase(input) {
1320
+ if (input.committedFingerprint === input.incomingFingerprint) return "current";
1321
+ if (input.pendingFingerprintAtProcessStart !== null) {
1322
+ return input.pendingFingerprintAtProcessStart === input.incomingFingerprint ? "publish_from_clean_restart" : "stage_pending_restart";
1323
+ }
1324
+ return "stage_pending_restart";
1325
+ }
1326
+ function initialCredentialBootstrapIsSafe(input) {
1327
+ return input.transitionPhase === "stage_pending_restart" && input.committedFingerprint === null && input.pendingFingerprintAtProcessStart === null && (input.bootstrapFingerprintAtProcessStart === null && input.credentialArtifactsProvenAbsentAtProcessStart || input.bootstrapFingerprintAtProcessStart === input.incomingFingerprint) && input.trackedChildTerminationProven && input.activeProcessCount === 0 && input.credentialProcessGroupCount === 0 && input.activePtyCount === 0;
940
1328
  }
941
- function credentialHelperForRemotes(remotes, storePath) {
942
- const credentialStore = writeCredentialStoreEntries(remotes, storePath);
943
- return credentialStore?.hasCredentials ? readOnlyCredentialStoreHelper(credentialStore.storePath) : null;
1329
+ async function fenceCredentialGenerationAtConfigureEntry(input) {
1330
+ if (input.currentFingerprint === input.incomingFingerprint) return false;
1331
+ input.revokeInMemory();
1332
+ await input.terminatePreviousGeneration();
1333
+ return true;
1334
+ }
1335
+ function requireCredentialGenerationRestart(required, fatalExit = (exitCode2) => process.exit(exitCode2), exitCode = 1) {
1336
+ if (!required) return;
1337
+ fatalExit(exitCode);
1338
+ throw new Error("Credential generation restart callback returned unexpectedly");
1339
+ }
1340
+ function pendingCredentialGenerationBoundary(input) {
1341
+ if (input.systemdContract && input.systemdInvocationId !== null && SYSTEMD_INVOCATION_IDENTIFIER_PATTERN.test(input.systemdInvocationId)) {
1342
+ return { kind: "systemd", machineId: input.machineId, invocationId: input.systemdInvocationId };
1343
+ }
1344
+ return { kind: "boot", machineId: input.machineId, bootSessionId: input.bootSessionId };
1345
+ }
1346
+ function credentialGenerationBoundaryIsComplete(boundary) {
1347
+ return boundary.machineId !== null && (boundary.kind === "systemd" || boundary.bootSessionId !== null);
1348
+ }
1349
+ function credentialGenerationRestartIsContained(input) {
1350
+ const pending = input.pendingBoundary;
1351
+ if (pending === null || pending.machineId === null || input.currentMachineId === null || pending.machineId !== input.currentMachineId) {
1352
+ return false;
1353
+ }
1354
+ if (pending.kind === "systemd") {
1355
+ return input.systemdContract && input.currentSystemdInvocationId !== null && SYSTEMD_INVOCATION_IDENTIFIER_PATTERN.test(input.currentSystemdInvocationId) && pending.invocationId !== input.currentSystemdInvocationId;
1356
+ }
1357
+ return pending.bootSessionId !== null && input.currentBootSessionId !== null && pending.bootSessionId !== input.currentBootSessionId;
1358
+ }
1359
+ function credentialReapContractStatus(probe) {
1360
+ let resolved = probe;
1361
+ if (!resolved) {
1362
+ if (process.platform !== "linux") return "direct";
1363
+ try {
1364
+ const cgroupText = fs.readFileSync("/proc/self/cgroup", "utf8");
1365
+ const unit = cgroupText.match(/(?:^|\/)r5d-worker\.service(?:$|\n)/m)?.[0]?.replace(/^\//, "").trim();
1366
+ if (unit !== "r5d-worker.service") {
1367
+ return SYSTEMD_INVOCATION_IDENTIFIER_PATTERN.test(process.env.INVOCATION_ID ?? "") ? "systemd_unverified" : "direct";
1368
+ }
1369
+ const result = Bun.spawnSync(
1370
+ [
1371
+ "/usr/bin/systemctl",
1372
+ "show",
1373
+ unit,
1374
+ "--no-pager",
1375
+ "--property=KillMode",
1376
+ "--property=Restart",
1377
+ "--property=SendSIGKILL",
1378
+ "--property=InvocationID"
1379
+ ],
1380
+ {
1381
+ stdout: "pipe",
1382
+ stderr: "pipe",
1383
+ env: workerGitProcessEnvironment(),
1384
+ timeout: 2e3
1385
+ }
1386
+ );
1387
+ if (result.exitCode !== 0) return "systemd_unverified";
1388
+ resolved = {
1389
+ platform: process.platform,
1390
+ invocationId: process.env.INVOCATION_ID,
1391
+ cgroupText,
1392
+ unitProperties: result.stdout.toString()
1393
+ };
1394
+ } catch {
1395
+ return "systemd_unverified";
1396
+ }
1397
+ }
1398
+ if (resolved.platform !== "linux") return "direct";
1399
+ if (!/(?:^|\/)r5d-worker\.service(?:$|\n)/m.test(resolved.cgroupText)) {
1400
+ return SYSTEMD_INVOCATION_IDENTIFIER_PATTERN.test(resolved.invocationId ?? "") ? "systemd_unverified" : "direct";
1401
+ }
1402
+ if (!SYSTEMD_INVOCATION_IDENTIFIER_PATTERN.test(resolved.invocationId ?? "")) return "systemd_unverified";
1403
+ const properties = new Map(
1404
+ resolved.unitProperties.split(/\r?\n/).filter(Boolean).map((line) => {
1405
+ const separator = line.indexOf("=");
1406
+ return separator < 1 ? [line, ""] : [line.slice(0, separator), line.slice(separator + 1)];
1407
+ })
1408
+ );
1409
+ return properties.get("KillMode") === "control-group" && properties.get("Restart") === "always" && properties.get("SendSIGKILL") === "yes" && properties.get("InvocationID")?.toLowerCase() === resolved.invocationId?.toLowerCase() ? "verified_systemd" : "systemd_unverified";
1410
+ }
1411
+ function verifiedCredentialReapContract(probe) {
1412
+ return credentialReapContractStatus(probe) === "verified_systemd";
1413
+ }
1414
+ function fenceUnsafeWorkspaceSyncFailure(input) {
1415
+ if (input.hydrationCurrent) return false;
1416
+ input.invalidateExecution();
1417
+ requireCredentialGenerationRestart(true, input.fatalExit, input.exitCode);
1418
+ return true;
944
1419
  }
945
1420
  const workerGitSecurityTestHarness = {
946
1421
  commandArgs: workerGitCommand.commandArgs,
@@ -952,7 +1427,45 @@ const workerGitSecurityTestHarness = {
952
1427
  pruneCredentialStoreFiles,
953
1428
  readOnlyCredentialStoreHelper,
954
1429
  resetCredentialStoreDirectory,
955
- replaceCredentialStoreFile
1430
+ replaceCredentialStoreFile,
1431
+ configureGitHubAuth(credential, filePaths, configureFiles = configureGitHubRegistryAuthFiles) {
1432
+ configureGitHubAuth(credential, filePaths, configureFiles);
1433
+ },
1434
+ githubCredential: () => githubCredential,
1435
+ credentialGenerationFingerprint,
1436
+ credentialGenerationTransitionPhase,
1437
+ initialCredentialBootstrapIsSafe,
1438
+ fenceCredentialGenerationAtConfigureEntry,
1439
+ requireCredentialGenerationRestart,
1440
+ pendingCredentialGenerationBoundary,
1441
+ credentialGenerationBoundaryIsComplete,
1442
+ credentialGenerationRestartIsContained,
1443
+ credentialReapContractStatus,
1444
+ verifiedCredentialReapContract,
1445
+ fenceUnsafeWorkspaceSyncFailure,
1446
+ assertWorkerChildAdmission,
1447
+ terminateCredentialBearingChildren,
1448
+ terminateCredentialBearingChildrenWithRetention,
1449
+ async commitCredentialGeneration(prepared, credential, children, beforeMutation, previousGenerationFenced = false) {
1450
+ return await commitCredentialGeneration(
1451
+ prepared,
1452
+ credential,
1453
+ async () => await terminateCredentialBearingChildren(children),
1454
+ beforeMutation,
1455
+ previousGenerationFenced
1456
+ );
1457
+ },
1458
+ credentialGenerationReceiptPath,
1459
+ credentialAuthorityLockPath,
1460
+ acquireCredentialAuthorityLock,
1461
+ releaseCredentialAuthorityLockHandle,
1462
+ pendingCredentialGenerationPath,
1463
+ pendingCredentialGenerationIntentContent,
1464
+ readPendingCredentialGenerationIntent,
1465
+ readBootSessionIdentifier,
1466
+ readMachineIdentifier,
1467
+ bootstrapCredentialGenerationPath,
1468
+ readCredentialGenerationReceipt
956
1469
  };
957
1470
  function dockerConfigPath() {
958
1471
  return path.join(os.homedir(), ".docker", "config.json");
@@ -960,39 +1473,14 @@ function dockerConfigPath() {
960
1473
  function containersAuthPath() {
961
1474
  return path.join(os.homedir(), ".config", "containers", "auth.json");
962
1475
  }
963
- function readJsonFile(filePath) {
964
- if (!fs.existsSync(filePath)) {
965
- return {};
966
- }
967
- try {
968
- const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
969
- return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
970
- } catch {
971
- return {};
972
- }
973
- }
974
- function writeRegistryAuthFile(filePath, credential) {
975
- const config = readJsonFile(filePath);
976
- const existingAuths = config.auths && typeof config.auths === "object" && !Array.isArray(config.auths) ? config.auths : {};
977
- const auths = existingAuths;
978
- auths[credential.registry] = {
979
- username: credential.username,
980
- password: credential.token,
981
- auth: Buffer.from(`${credential.username}:${credential.token}`).toString("base64")
982
- };
983
- config.auths = auths;
984
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
985
- fs.writeFileSync(filePath, `${JSON.stringify(config, null, 2)}
986
- `, { mode: 384 });
987
- fs.chmodSync(filePath, 384);
1476
+ function configureGitHubAuth(credential, filePaths = [dockerConfigPath(), containersAuthPath()], configureFiles = configureGitHubRegistryAuthFiles) {
1477
+ githubCredential = null;
1478
+ configureFiles(filePaths, credential);
1479
+ installGitHubCredentialGeneration(credential);
988
1480
  }
989
- function configureGitHubAuth(credential) {
1481
+ function installGitHubCredentialGeneration(credential) {
990
1482
  githubCredential = credential;
991
- if (!credential) {
992
- return;
993
- }
994
- writeRegistryAuthFile(dockerConfigPath(), credential);
995
- writeRegistryAuthFile(containersAuthPath(), credential);
1483
+ if (!credential) return;
996
1484
  if (credential.missingScopes.length > 0) {
997
1485
  process.stderr.write(
998
1486
  `[r5d-worker] GitHub token is missing scope(s): ${credential.missingScopes.join(", ")}. Sign in with GitHub again if gh or GHCR access fails.
@@ -1003,6 +1491,21 @@ function configureGitHubAuth(credential) {
1003
1491
  `);
1004
1492
  }
1005
1493
  }
1494
+ async function commitCredentialGeneration(prepared, credential, terminatePreviousGeneration, beforeMutation, previousGenerationFenced = false) {
1495
+ const changed = prepared.changed;
1496
+ try {
1497
+ if (changed && !previousGenerationFenced) {
1498
+ githubCredential = null;
1499
+ await terminatePreviousGeneration();
1500
+ }
1501
+ prepared.commit(beforeMutation);
1502
+ } catch (error) {
1503
+ prepared.discard();
1504
+ throw error instanceof RegistryAuthConfigurationError ? error : new RegistryAuthConfigurationError(error);
1505
+ }
1506
+ installGitHubCredentialGeneration(credential);
1507
+ return changed;
1508
+ }
1006
1509
  function containerRegistryEnv() {
1007
1510
  if (!githubCredential) {
1008
1511
  return {};
@@ -1032,6 +1535,44 @@ function githubProcessEnv() {
1032
1535
  ...githubCliEnv(githubCredential?.token)
1033
1536
  };
1034
1537
  }
1538
+ const INHERITED_WORKER_CREDENTIAL_ENV_KEYS = [
1539
+ WORKER_RUNTIME_ENV,
1540
+ "INVOCATION_ID",
1541
+ "R5D_WORKER_TOKEN",
1542
+ "R5D_TOKEN",
1543
+ "R5DCTL_TOKEN",
1544
+ "R5D_API_KEY",
1545
+ "R5DCTL_API_KEY",
1546
+ "GH_TOKEN",
1547
+ "GITHUB_TOKEN",
1548
+ "GH_ENTERPRISE_TOKEN",
1549
+ "DOCKER_AUTH_CONFIG",
1550
+ "REGISTRY_AUTH_FILE",
1551
+ "R5D_GHCR_AUTH_FILE",
1552
+ "NODE_AUTH_TOKEN",
1553
+ "NPM_TOKEN",
1554
+ "BUN_AUTH_TOKEN"
1555
+ ];
1556
+ function workerChildProcessEnvironment(layers, inherited = process.env, platform = process.platform) {
1557
+ const environment = {};
1558
+ const reservedExact = new Set(INHERITED_WORKER_CREDENTIAL_ENV_KEYS);
1559
+ const reservedFolded = new Set(INHERITED_WORKER_CREDENTIAL_ENV_KEYS.map((key) => key.toLowerCase()));
1560
+ const assignLayer = (source, scrubInheritedCredentials) => {
1561
+ for (const [key, value] of Object.entries(source)) {
1562
+ const folded = key.toLowerCase();
1563
+ if (scrubInheritedCredentials && (platform === "win32" ? reservedFolded.has(folded) : reservedExact.has(key))) continue;
1564
+ if (platform === "win32") {
1565
+ for (const existing of Object.keys(environment)) {
1566
+ if (existing.toLowerCase() === folded) delete environment[existing];
1567
+ }
1568
+ }
1569
+ environment[key] = value;
1570
+ }
1571
+ };
1572
+ assignLayer(inherited, true);
1573
+ for (const layer of layers) assignLayer(layer, false);
1574
+ return environment;
1575
+ }
1035
1576
  function primaryProjectBranch(config) {
1036
1577
  if (config.branches.length === 0) throw new Error(`Project ${config.projectId} has no configured branches`);
1037
1578
  if (config.branches.some(({ branchName }) => branchName === "main")) return "main";
@@ -1059,6 +1600,8 @@ function describeWorkerSessionTarget(target) {
1059
1600
  function resolveWorkerSessionTarget(input) {
1060
1601
  if (input.target.type === "workspace") {
1061
1602
  if (input.target.rootProfile === "visible_projects") {
1603
+ const disabled = [...input.projectConfigById.values()].find(({ executionDisabled }) => executionDisabled);
1604
+ if (disabled) assertRepositoryExecutionEnabled(disabled);
1062
1605
  return { target: input.target, rootPath: input.projectsRoot };
1063
1606
  }
1064
1607
  if (!fs.existsSync(path.join(input.workspaceShadowRoot, ".git"))) {
@@ -1069,6 +1612,7 @@ function resolveWorkerSessionTarget(input) {
1069
1612
  const target = input.target;
1070
1613
  const config = input.projectConfigById.get(target.projectId);
1071
1614
  if (!config) throw new Error(`Project ${target.projectId} is missing from the worker workspace configuration`);
1615
+ assertRepositoryExecutionEnabled(config);
1072
1616
  if (!config.branches.some(({ branchName }) => branchName === target.branchName)) {
1073
1617
  throw new Error(`Project branch ${target.projectId}/${target.branchName} is missing from the worker workspace configuration`);
1074
1618
  }
@@ -1768,6 +2312,7 @@ async function prepareShellEnvForTarget(input) {
1768
2312
  async function executeCommand(input) {
1769
2313
  const startedAt = Date.now();
1770
2314
  let timeout;
2315
+ let spawnedProcess;
1771
2316
  try {
1772
2317
  const targetProcessEnv = await prepareShellEnvForTarget({
1773
2318
  target: input.resolvedTarget.target,
@@ -1779,18 +2324,17 @@ async function executeCommand(input) {
1779
2324
  activePlanId: input.message.env?.R5D_ACTIVE_PLAN_ID
1780
2325
  });
1781
2326
  const cwd = resolveCommandCwd(input.resolvedTarget.rootPath, input.message.cwd);
2327
+ input.assertAdmission();
1782
2328
  const subprocess = Bun.spawn(input.message.argv, {
1783
2329
  cwd,
1784
2330
  stdout: "pipe",
1785
2331
  stderr: "pipe",
1786
2332
  detached: true,
1787
- env: {
1788
- ...process.env,
1789
- ...githubProcessEnv(),
1790
- ...input.message.env ?? {},
1791
- ...targetProcessEnv
1792
- }
2333
+ env: workerChildProcessEnvironment([githubProcessEnv(), input.message.env ?? {}, targetProcessEnv])
1793
2334
  });
2335
+ spawnedProcess = subprocess;
2336
+ credentialBearingProcessGroups.set(subprocess.pid, subprocess);
2337
+ credentialBearingProcessGroupTargets.set(subprocess.pid, input.resolvedTarget.target);
1794
2338
  activeProcesses.set(input.message.runId, {
1795
2339
  process: subprocess,
1796
2340
  target: input.resolvedTarget.target,
@@ -1827,6 +2371,7 @@ async function executeCommand(input) {
1827
2371
  durationMs: Date.now() - startedAt
1828
2372
  };
1829
2373
  } catch (error) {
2374
+ if (error instanceof StaleWorkerAdmissionError) throw error;
1830
2375
  return {
1831
2376
  type: "exec_result",
1832
2377
  requestId: input.message.requestId,
@@ -1840,6 +2385,9 @@ async function executeCommand(input) {
1840
2385
  if (timeout) {
1841
2386
  clearTimeout(timeout);
1842
2387
  }
2388
+ if (spawnedProcess) {
2389
+ await reapCompletedCredentialBearingProcessGroup(input.message.runId, spawnedProcess);
2390
+ }
1843
2391
  activeProcesses.delete(input.message.runId);
1844
2392
  }
1845
2393
  }
@@ -1848,6 +2396,7 @@ async function executeStreamingCommand(input) {
1848
2396
  let started = false;
1849
2397
  let timedOut = false;
1850
2398
  let timeout;
2399
+ let spawnedProcess;
1851
2400
  try {
1852
2401
  if (cancelledProcessRuns.delete(input.message.runId)) {
1853
2402
  sendWorkerMessage(input.ws, {
@@ -1869,6 +2418,7 @@ async function executeStreamingCommand(input) {
1869
2418
  });
1870
2419
  const cwd = resolveCommandCwd(input.resolvedTarget.rootPath, input.message.cwd);
1871
2420
  const interactive = input.message.interactive === true;
2421
+ input.assertAdmission();
1872
2422
  const subprocess = Bun.spawn(input.message.argv, {
1873
2423
  cwd,
1874
2424
  // Without an explicit stdin the process reads /dev/null and interactive
@@ -1877,13 +2427,11 @@ async function executeStreamingCommand(input) {
1877
2427
  stdout: "pipe",
1878
2428
  stderr: "pipe",
1879
2429
  detached: true,
1880
- env: {
1881
- ...process.env,
1882
- ...githubProcessEnv(),
1883
- ...input.message.env ?? {},
1884
- ...targetProcessEnv
1885
- }
2430
+ env: workerChildProcessEnvironment([githubProcessEnv(), input.message.env ?? {}, targetProcessEnv])
1886
2431
  });
2432
+ spawnedProcess = subprocess;
2433
+ credentialBearingProcessGroups.set(subprocess.pid, subprocess);
2434
+ credentialBearingProcessGroupTargets.set(subprocess.pid, input.resolvedTarget.target);
1887
2435
  activeProcesses.set(input.message.runId, {
1888
2436
  process: subprocess,
1889
2437
  target: input.resolvedTarget.target,
@@ -1948,6 +2496,7 @@ async function executeStreamingCommand(input) {
1948
2496
  pendingProcessTerminals.set(input.message.runId, terminal);
1949
2497
  sendWorkerMessage(input.ws, terminal);
1950
2498
  } catch (error) {
2499
+ if (!started && error instanceof StaleWorkerAdmissionError) throw error;
1951
2500
  const message = error instanceof Error ? error.message : String(error);
1952
2501
  if (started) {
1953
2502
  const terminal = {
@@ -1971,10 +2520,27 @@ async function executeStreamingCommand(input) {
1971
2520
  clearTimeout(timeout);
1972
2521
  }
1973
2522
  closeProcessStdin(activeProcesses.get(input.message.runId));
2523
+ if (spawnedProcess) {
2524
+ await reapCompletedCredentialBearingProcessGroup(input.message.runId, spawnedProcess);
2525
+ }
1974
2526
  activeProcesses.delete(input.message.runId);
1975
2527
  cancelledProcessRuns.delete(input.message.runId);
1976
2528
  }
1977
2529
  }
2530
+ async function reapCompletedCredentialBearingProcessGroup(runId, subprocess) {
2531
+ try {
2532
+ await terminateProcessTree(subprocess);
2533
+ if (credentialBearingProcessGroups.get(subprocess.pid) === subprocess) {
2534
+ credentialBearingProcessGroups.delete(subprocess.pid);
2535
+ credentialBearingProcessGroupTargets.delete(subprocess.pid);
2536
+ }
2537
+ } catch (error) {
2538
+ process.stderr.write(
2539
+ `[r5d-worker] retained unreaped credential-bearing process group ${subprocess.pid} from ${runId}: ${error instanceof Error ? error.message : String(error)}
2540
+ `
2541
+ );
2542
+ }
2543
+ }
1978
2544
  function closeProcessStdin(active) {
1979
2545
  if (!active?.stdin) {
1980
2546
  return;
@@ -1994,6 +2560,15 @@ function sendWorkerMessage(ws, message) {
1994
2560
  `);
1995
2561
  }
1996
2562
  }
2563
+ function sendWorkerMessageFromCurrentSource(ws, message) {
2564
+ if (currentWorkerSocket !== ws || ws.readyState !== WebSocket.OPEN) return;
2565
+ try {
2566
+ ws.send(JSON.stringify(message));
2567
+ } catch (error) {
2568
+ process.stderr.write(`[r5d-worker] failed to send ${message.type}: ${error instanceof Error ? error.message : String(error)}
2569
+ `);
2570
+ }
2571
+ }
1997
2572
  function buildActiveProcessReports() {
1998
2573
  return Array.from(activeProcesses.entries()).filter(([, active]) => active.sessionId.length > 0).map(([runId, active]) => ({
1999
2574
  runId,
@@ -2073,6 +2648,19 @@ rl.on("line", (line) => {
2073
2648
 
2074
2649
  if (message.type === "close") {
2075
2650
  ptyProcess.kill();
2651
+ return;
2652
+ }
2653
+
2654
+ if (message.type === "force_close") {
2655
+ if (process.platform !== "win32" && Number.isSafeInteger(ptyProcess.pid) && ptyProcess.pid > 0) {
2656
+ try {
2657
+ process.kill(-ptyProcess.pid, "SIGKILL");
2658
+ } catch (error) {
2659
+ if (!error || error.code !== "ESRCH") throw error;
2660
+ }
2661
+ } else {
2662
+ ptyProcess.kill("SIGKILL");
2663
+ }
2076
2664
  }
2077
2665
  } catch (error) {
2078
2666
  send({ type: "error", error: error instanceof Error ? error.message : String(error) });
@@ -2104,14 +2692,26 @@ function nodePtyModulePaths() {
2104
2692
  }
2105
2693
  function createNodePtyBridge(options) {
2106
2694
  const child = spawnChildProcess("node", ["-e", PTY_BRIDGE_SCRIPT], {
2107
- env: {
2108
- ...process.env,
2109
- NODE_PATH: nodePtyModulePaths()
2110
- }
2695
+ env: workerChildProcessEnvironment([{ NODE_PATH: nodePtyModulePaths() }])
2111
2696
  });
2112
2697
  let lineBuffer = "";
2113
2698
  let emittedTerminalEvent = false;
2114
2699
  let stderr = "";
2700
+ let resolveTerminal;
2701
+ const terminal = new Promise((resolve) => {
2702
+ resolveTerminal = resolve;
2703
+ });
2704
+ const markTerminal = () => {
2705
+ resolveTerminal?.();
2706
+ resolveTerminal = void 0;
2707
+ };
2708
+ const terminalWithin = async (timeoutMs) => await Promise.race([
2709
+ terminal.then(() => true),
2710
+ new Promise((resolve) => {
2711
+ const timer = setTimeout(() => resolve(false), timeoutMs);
2712
+ timer.unref();
2713
+ })
2714
+ ]);
2115
2715
  const sendToChild = (message) => {
2116
2716
  if (child.stdin.writable) {
2117
2717
  child.stdin.write(`${JSON.stringify(message)}
@@ -2129,11 +2729,13 @@ function createNodePtyBridge(options) {
2129
2729
  }
2130
2730
  if (event.type === "exit") {
2131
2731
  emittedTerminalEvent = true;
2732
+ markTerminal();
2132
2733
  options.onExit({ exitCode: event.exitCode, signal: event.signal });
2133
2734
  return;
2134
2735
  }
2135
2736
  if (event.type === "error") {
2136
2737
  emittedTerminalEvent = true;
2738
+ markTerminal();
2137
2739
  options.onError(new Error(event.error));
2138
2740
  }
2139
2741
  };
@@ -2162,9 +2764,11 @@ function createNodePtyBridge(options) {
2162
2764
  });
2163
2765
  child.on("error", (error) => {
2164
2766
  emittedTerminalEvent = true;
2767
+ markTerminal();
2165
2768
  options.onError(error);
2166
2769
  });
2167
2770
  child.on("exit", (code, signal) => {
2771
+ markTerminal();
2168
2772
  if (emittedTerminalEvent) {
2169
2773
  return;
2170
2774
  }
@@ -2185,12 +2789,15 @@ function createNodePtyBridge(options) {
2185
2789
  sendToChild({ type: "resize", cols, rows });
2186
2790
  },
2187
2791
  kill() {
2792
+ void this.terminate().catch((error) => options.onTerminationError(error instanceof Error ? error : new Error(String(error))));
2793
+ },
2794
+ async terminate() {
2188
2795
  sendToChild({ type: "close" });
2189
- setTimeout(() => {
2190
- if (!child.killed) {
2191
- child.kill();
2192
- }
2193
- }, 500);
2796
+ if (await terminalWithin(500)) return;
2797
+ sendToChild({ type: "force_close" });
2798
+ if (await terminalWithin(500)) return;
2799
+ child.kill("SIGKILL");
2800
+ if (!await terminalWithin(500)) throw new Error(`PTY bridge ${child.pid ?? "unknown"} did not terminate after SIGKILL`);
2194
2801
  }
2195
2802
  };
2196
2803
  }
@@ -2228,6 +2835,7 @@ async function openPty(input) {
2228
2835
  });
2229
2836
  }
2230
2837
  });
2838
+ input.assertAdmission();
2231
2839
  const ptyProcess = createNodePtyBridge({
2232
2840
  file: shell.file,
2233
2841
  args: shell.args,
@@ -2236,14 +2844,7 @@ async function openPty(input) {
2236
2844
  cols: Math.max(1, Math.min(Math.floor(input.message.cols || 80), 500)),
2237
2845
  rows: Math.max(1, Math.min(Math.floor(input.message.rows || 24), 500)),
2238
2846
  cwd: input.resolvedTarget.rootPath,
2239
- env: {
2240
- ...process.env,
2241
- ...githubProcessEnv(),
2242
- ...input.message.env ?? {},
2243
- ...planProcessEnv,
2244
- ...targetProcessEnv,
2245
- ...shell.env ?? {}
2246
- }
2847
+ env: workerChildProcessEnvironment([githubProcessEnv(), input.message.env ?? {}, planProcessEnv, targetProcessEnv, shell.env ?? {}])
2247
2848
  },
2248
2849
  onOpened: () => {
2249
2850
  sendWorkerMessage(input.ws, {
@@ -2278,6 +2879,14 @@ async function openPty(input) {
2278
2879
  ptyId: input.message.ptyId,
2279
2880
  error: error.message
2280
2881
  });
2882
+ },
2883
+ onTerminationError: (error) => {
2884
+ sendWorkerMessage(input.ws, {
2885
+ type: "pty_error",
2886
+ requestId: input.message.requestId,
2887
+ ptyId: input.message.ptyId,
2888
+ error: error.message
2889
+ });
2281
2890
  }
2282
2891
  });
2283
2892
  activePtys.set(input.message.ptyId, {
@@ -2310,14 +2919,62 @@ function closePty(message) {
2310
2919
  if (!ptyProcess) {
2311
2920
  return;
2312
2921
  }
2313
- activePtys.delete(message.ptyId);
2314
2922
  ptyProcess.kill();
2315
2923
  }
2316
2924
  function closeAllPtys() {
2317
2925
  for (const ptyProcess of activePtys.values()) {
2318
2926
  ptyProcess.kill();
2319
2927
  }
2320
- activePtys.clear();
2928
+ }
2929
+ async function terminateCredentialBearingChildren(children) {
2930
+ const results = await Promise.allSettled(children.map(async ({ terminate }) => await terminate()));
2931
+ const failures = results.flatMap(
2932
+ (result, index) => result.status === "rejected" ? [new Error(`Failed to terminate ${children[index].label}`, { cause: result.reason })] : []
2933
+ );
2934
+ if (failures.length > 0) throw new AggregateError(failures, "Credential-bearing child termination was incomplete");
2935
+ }
2936
+ async function terminateCredentialBearingChildrenWithRetention(children) {
2937
+ const results = await Promise.allSettled(children.map(async ({ terminate }) => await terminate()));
2938
+ const failures = [];
2939
+ results.forEach((result, index) => {
2940
+ const child = children[index];
2941
+ if (result.status === "fulfilled") {
2942
+ child.onTerminated();
2943
+ return;
2944
+ }
2945
+ failures.push(new Error(`Failed to terminate ${child.label}`, { cause: result.reason }));
2946
+ });
2947
+ if (failures.length > 0) throw new AggregateError(failures, "Credential-bearing child termination was incomplete");
2948
+ }
2949
+ async function terminateActiveCredentialBearingChildren() {
2950
+ const processGroups = [...credentialBearingProcessGroups.entries()];
2951
+ const ptyEntries = [...activePtys.entries()];
2952
+ for (const active of activeProcesses.values()) closeProcessStdin(active);
2953
+ await terminateCredentialBearingChildrenWithRetention([
2954
+ ...processGroups.map(([processGroupId, subprocess]) => ({
2955
+ label: `process group ${processGroupId}`,
2956
+ terminate: async () => await terminateProcessTree(subprocess),
2957
+ onTerminated: () => {
2958
+ if (credentialBearingProcessGroups.get(processGroupId) === subprocess) {
2959
+ credentialBearingProcessGroups.delete(processGroupId);
2960
+ credentialBearingProcessGroupTargets.delete(processGroupId);
2961
+ }
2962
+ for (const [runId, active] of activeProcesses) {
2963
+ if (active.process === subprocess) activeProcesses.delete(runId);
2964
+ }
2965
+ }
2966
+ })),
2967
+ ...ptyEntries.map(([ptyId, active]) => ({
2968
+ label: `PTY ${ptyId}`,
2969
+ terminate: async () => await active.terminate(),
2970
+ onTerminated: () => {
2971
+ if (activePtys.get(ptyId) === active) activePtys.delete(ptyId);
2972
+ }
2973
+ }))
2974
+ ]);
2975
+ if (activeProcesses.size > 0 || credentialBearingProcessGroups.size > 0 || credentialBearingProcessGroupTargets.size > 0 || activePtys.size > 0) {
2976
+ throw new Error("Credential-bearing child ownership remained after termination");
2977
+ }
2321
2978
  }
2322
2979
  function websocketUrl(baseUrl, label) {
2323
2980
  const url = new URL("/worker/ws", baseUrl);
@@ -2327,6 +2984,7 @@ function websocketUrl(baseUrl, label) {
2327
2984
  return url.toString();
2328
2985
  }
2329
2986
  async function startWorker(options) {
2987
+ advanceWorkerAdmissionGeneration();
2330
2988
  process.on("uncaughtException", (error) => {
2331
2989
  process.stderr.write(`[r5d-worker] uncaught exception: ${error instanceof Error ? error.stack ?? error.message : String(error)}
2332
2990
  `);
@@ -2345,8 +3003,15 @@ async function startWorker(options) {
2345
3003
  const projectsRoot = path.resolve(options.projectRoot ?? defaultProjectsRoot());
2346
3004
  const syncRoot = path.resolve(options.syncRoot ?? process.env.R5D_SYNC_ROOT ?? defaultSyncRoot());
2347
3005
  const workspaceShadowRoot = path.join(syncRoot, "workspace");
3006
+ const projectWorktreeSnapshotsRoot = path.join(syncRoot, "project-worktree-snapshots");
2348
3007
  const artifactRoot = path.resolve(options.artifactRoot ?? process.env.R5D_ARTIFACTS_ROOT ?? defaultArtifactRoot());
2349
3008
  const planRoot = path.resolve(options.planRoot ?? process.env.R5D_PLANS_ROOT ?? defaultPlanRoot());
3009
+ assertDisjointManagedRoots([
3010
+ { label: "projects root", rootPath: projectsRoot },
3011
+ { label: "sync root", rootPath: syncRoot },
3012
+ { label: "artifacts root", rootPath: artifactRoot },
3013
+ { label: "plans root", rootPath: planRoot }
3014
+ ]);
2350
3015
  process.stdout.write(`[r5d-worker] label: ${label}
2351
3016
  `);
2352
3017
  process.stdout.write(`[r5d-worker] projects root: ${projectsRoot}
@@ -2359,26 +3024,43 @@ async function startWorker(options) {
2359
3024
  `);
2360
3025
  process.stdout.write(`[r5d-worker] server: ${baseUrl}
2361
3026
  `);
2362
- const snapshotCleanup = cleanupStaleProjectWorktreeSnapshots();
2363
- if (snapshotCleanup.removed.length > 0) {
2364
- process.stdout.write(`[r5d-worker] removed ${snapshotCleanup.removed.length} stale project snapshot(s)
2365
- `);
2366
- }
2367
- for (const failure of snapshotCleanup.failed) {
2368
- process.stderr.write(`[r5d-worker] failed to remove stale project snapshot ${failure.path}: ${failure.error}
2369
- `);
2370
- }
2371
3027
  runGit(["--version"]);
2372
3028
  await verifyR5dctlAuth(baseUrl, token);
2373
- fs.mkdirSync(projectsRoot, { recursive: true });
2374
- fs.mkdirSync(syncRoot, { recursive: true });
2375
- fs.mkdirSync(artifactRoot, { recursive: true });
2376
- fs.mkdirSync(planRoot, { recursive: true });
2377
- const projectWorkspaceStateStore = new ProjectWorkspaceStateStore({
2378
- stateRoot: path.join(syncRoot, "project-state"),
2379
- projectsRoot
3029
+ const initializedWorkspaceState = await workspaceSyncSingleFlight.runExclusive(() => {
3030
+ if (!startupProjectSnapshotRecoveryCompleted) {
3031
+ const snapshotRecovery = recoverStaleProjectWorktreeSnapshots({
3032
+ projectsRoot,
3033
+ temporaryRoot: projectWorktreeSnapshotsRoot
3034
+ });
3035
+ if (snapshotRecovery.restored.length > 0) {
3036
+ process.stdout.write(`[r5d-worker] restored ${snapshotRecovery.restored.length} interrupted project snapshot(s)
3037
+ `);
3038
+ }
3039
+ if (snapshotRecovery.removed.length > 0) {
3040
+ process.stdout.write(`[r5d-worker] removed ${snapshotRecovery.removed.length} completed/incomplete project snapshot(s)
3041
+ `);
3042
+ }
3043
+ for (const failure of snapshotRecovery.failed) {
3044
+ process.stderr.write(`[r5d-worker] failed to recover stale project snapshot ${failure.path}: ${failure.error}
3045
+ `);
3046
+ }
3047
+ if (snapshotRecovery.failed.length > 0) {
3048
+ throw new Error("Project snapshot recovery is incomplete; refusing to mutate project checkouts");
3049
+ }
3050
+ startupProjectSnapshotRecoveryCompleted = true;
3051
+ }
3052
+ fs.mkdirSync(projectsRoot, { recursive: true });
3053
+ fs.mkdirSync(syncRoot, { recursive: true });
3054
+ fs.mkdirSync(artifactRoot, { recursive: true });
3055
+ fs.mkdirSync(planRoot, { recursive: true });
3056
+ const store = new ProjectWorkspaceStateStore({
3057
+ stateRoot: path.join(syncRoot, "project-state"),
3058
+ projectsRoot
3059
+ });
3060
+ return { store, state: store.read() };
2380
3061
  });
2381
- let projectWorkspaceState = projectWorkspaceStateStore.read();
3062
+ const projectWorkspaceStateStore = initializedWorkspaceState.store;
3063
+ let projectWorkspaceState = initializedWorkspaceState.state;
2382
3064
  const projectConfigById = /* @__PURE__ */ new Map();
2383
3065
  const pendingCheckouts = /* @__PURE__ */ new Map();
2384
3066
  const readyProjectIds = /* @__PURE__ */ new Set();
@@ -2394,6 +3076,7 @@ async function startWorker(options) {
2394
3076
  let workspaceAutomaticTimer;
2395
3077
  let workspacePeriodicTimer;
2396
3078
  let pendingAutomaticTrigger;
3079
+ const pendingCreatedBranchPublicationNotBefore = /* @__PURE__ */ new Map();
2397
3080
  let automaticSyncInFlight = false;
2398
3081
  let terminalReplayTimer;
2399
3082
  let workspaceSyncRequestsInFlight = 0;
@@ -2408,7 +3091,7 @@ async function startWorker(options) {
2408
3091
  const projectPlanMountId = (projectId, branchName) => `plan:${projectId}:${encodeURIComponent(branchName)}`;
2409
3092
  const workspaceProjectRelativePath = (projectId, branchName) => path.posix.join("projects", projectId, "branches", encodeURIComponent(branchName));
2410
3093
  const workspacePlanRelativePath = (projectId, branchName) => path.posix.join("plans", projectId, encodeURIComponent(branchName));
2411
- const projectConnection = (project) => {
3094
+ const projectConnection = (project, desiredFiles) => {
2412
3095
  const mirrorUrl = canonicalProjectRemoteUrl(baseUrl, project.projectId);
2413
3096
  const originUrl = project.repoHttpUrl ?? mirrorUrl;
2414
3097
  const originHeader = project.repoHttpUrl ? project.repoAuthHeader : bearerAuthHeader;
@@ -2417,7 +3100,8 @@ async function startWorker(options) {
2417
3100
  { remoteUrl: mirrorUrl, authHeader: bearerAuthHeader },
2418
3101
  { remoteUrl: originUrl, authHeader: originHeader }
2419
3102
  ],
2420
- credentialStorePathForProject(project.projectId)
3103
+ credentialStorePathForProject(project.projectId),
3104
+ desiredFiles
2421
3105
  );
2422
3106
  if (!credentialHelper) throw new Error("Project mirror credentials are unavailable");
2423
3107
  return {
@@ -2428,11 +3112,12 @@ async function startWorker(options) {
2428
3112
  credentialHelper
2429
3113
  };
2430
3114
  };
2431
- const projectMirrorConnection = (projectId) => {
3115
+ const projectMirrorConnection = (projectId, desiredFiles) => {
2432
3116
  const mirrorUrl = canonicalProjectRemoteUrl(baseUrl, projectId);
2433
3117
  const credentialHelper = credentialHelperForRemotes(
2434
3118
  [{ remoteUrl: mirrorUrl, authHeader: bearerAuthHeader }],
2435
- credentialStorePathForProject(projectId)
3119
+ credentialStorePathForProject(projectId),
3120
+ desiredFiles
2436
3121
  );
2437
3122
  if (!credentialHelper) throw new Error("Project mirror credentials are unavailable");
2438
3123
  return { mirrorUrl, credentialHelper, mirrorCredentialUsername: workerCredentialUsername };
@@ -2505,6 +3190,29 @@ async function startWorker(options) {
2505
3190
  readyProjectIds.delete(project.projectId);
2506
3191
  reconciledProjectConfigFingerprints.delete(project.projectId);
2507
3192
  };
3193
+ const deactivatePreserveOnlyProjectBranch = (project, branchName) => {
3194
+ const branchPath = configuredProjectBranchPath(projectsRoot, project, branchName);
3195
+ pendingCheckouts.delete(projectBranchKey(project.projectId, branchName));
3196
+ if (!fs.existsSync(branchPath)) return;
3197
+ if (!hasProjectWorktree(branchPath)) {
3198
+ throw new Error(`Preserve-only project branch path ${project.projectId}/${branchName} is not a managed Git checkout`);
3199
+ }
3200
+ if (project.branches.length === 0) {
3201
+ throw new Error(`Cannot hide preserve-only project branch ${project.projectId}/${branchName} without an active anchor branch`);
3202
+ }
3203
+ const primaryBranchName = primaryProjectBranch(project);
3204
+ if (branchName === primaryBranchName) {
3205
+ throw new Error(`Cannot hide preserve-only project branch ${project.projectId}/${branchName} because it is the active anchor`);
3206
+ }
3207
+ deleteLinkedProjectBranch({
3208
+ projectRoot: configuredProjectRoot(projectsRoot, project),
3209
+ primaryBranchName,
3210
+ branchName
3211
+ });
3212
+ fs.rmSync(path.join(planRoot, project.projectId, ...branchName.split("/")), { recursive: true, force: true });
3213
+ readyProjectIds.delete(project.projectId);
3214
+ reconciledProjectConfigFingerprints.delete(project.projectId);
3215
+ };
2508
3216
  const cleanupConfigForDurableDeletion = (deletion) => {
2509
3217
  const desiredLayout = projectWorkspaceState.desiredProjects.find(
2510
3218
  (project) => project.projectId === deletion.projectId && project.checkoutPathSegments[0] === deletion.checkoutPathSegments[0] && project.checkoutPathSegments[1] === deletion.checkoutPathSegments[1]
@@ -2519,8 +3227,13 @@ async function startWorker(options) {
2519
3227
  repoHttpUrl: liveConfig?.repoHttpUrl ?? null,
2520
3228
  repoAuthHeader: liveConfig?.repoAuthHeader ?? null,
2521
3229
  defaultBranch,
3230
+ repositoryTransitionId: liveConfig?.repositoryTransitionId ?? null,
3231
+ executionDisabled: false,
3232
+ mirrorWritesDisabled: false,
3233
+ preserveOnlyBranches: [],
2522
3234
  branches: branchNames.map(
2523
3235
  (branchName) => liveConfig?.branches.find((branch) => branch.branchName === branchName) ?? {
3236
+ branchId: "deleted",
2524
3237
  branchName,
2525
3238
  sourceBranchName: null,
2526
3239
  // Deletion never consumes the baseline, but retain a structurally
@@ -2535,9 +3248,18 @@ async function startWorker(options) {
2535
3248
  const cleanupConfig = cleanupConfigForDurableDeletion(deletion);
2536
3249
  if (deletion.kind === "project") {
2537
3250
  if (!deletion.projectDeleted) {
2538
- if (fs.existsSync(deletion.managedPath)) {
2539
- removeProjectWorktrees({ projectRoot: deletion.managedPath, branchNames: deletion.branchNames });
2540
- }
3251
+ const nextProject = projectConfigById.get(deletion.projectId);
3252
+ if (!nextProject) throw new Error(`Checkout-path move lost desired project ${deletion.projectId}`);
3253
+ preserveProjectCheckoutPathMove({
3254
+ oldProjectRoot: deletion.managedPath,
3255
+ newProjectRoot: configuredProjectRoot(projectsRoot, nextProject),
3256
+ projectsDurabilityRoot: projectsRoot,
3257
+ outerWorkspaceRoot: workspaceShadowRoot,
3258
+ branches: deletion.branchNames.map((branchName) => ({
3259
+ branchName,
3260
+ outerWorkspaceRelativePath: workspaceProjectRelativePath(deletion.projectId, branchName)
3261
+ }))
3262
+ });
2541
3263
  readyProjectIds.delete(deletion.projectId);
2542
3264
  reconciledProjectConfigFingerprints.delete(deletion.projectId);
2543
3265
  continue;
@@ -2570,32 +3292,15 @@ async function startWorker(options) {
2570
3292
  });
2571
3293
  }
2572
3294
  };
2573
- const pruneStaleOuterWorkspaceEntries = (entries) => {
2574
- const remove = (candidate) => {
2575
- if (candidate) fs.rmSync(candidate, { recursive: true, force: true });
2576
- };
2577
- for (const entry of entries) {
2578
- if (entry.kind === "branch") {
2579
- projectWorkspaceState = projectWorkspaceStateStore.beginBranchDeletion({
2580
- projectId: entry.projectId,
2581
- branchName: entry.branchName
2582
- });
2583
- remove(entry.projectTreePath);
2584
- remove(entry.planTreePath);
2585
- continue;
2586
- }
2587
- remove(entry.projectTreePath);
2588
- remove(entry.planTreePath);
2589
- for (const branch of entry.branches) {
2590
- remove(branch.projectTreePath);
2591
- remove(branch.planTreePath);
2592
- }
2593
- }
2594
- };
2595
3295
  const effectiveWorkerProjects = (projects) => {
2596
3296
  const incomingById = new Map(projects.map((project) => [project.projectId, project]));
2597
- const pending = new Set(
2598
- projectWorkspaceState.locallyPendingCreatedBranches.map(({ projectId, branchName }) => projectBranchKey(projectId, branchName))
3297
+ const preserveOnlyByKey = new Map(
3298
+ projects.flatMap(
3299
+ (project) => project.preserveOnlyBranches.map((branch) => [projectBranchKey(project.projectId, branch.branchName), branch])
3300
+ )
3301
+ );
3302
+ const pending = new Map(
3303
+ projectWorkspaceState.locallyPendingCreatedBranches.map((branch) => [projectBranchKey(branch.projectId, branch.branchName), branch])
2599
3304
  );
2600
3305
  return projectWorkspaceState.effectiveDesiredProjects.flatMap((layout) => {
2601
3306
  const incoming = incomingById.get(layout.projectId);
@@ -2603,10 +3308,21 @@ async function startWorker(options) {
2603
3308
  const existing = projectConfigById.get(layout.projectId);
2604
3309
  const branches = layout.branches.flatMap(({ branchName }) => {
2605
3310
  const configured = incoming.branches.find((branch) => branch.branchName === branchName);
2606
- if (configured) return [configured];
3311
+ const pendingBranch = pending.get(projectBranchKey(layout.projectId, branchName));
3312
+ const preservedBranch = preserveOnlyByKey.get(projectBranchKey(layout.projectId, branchName));
3313
+ const pendingIncarnationIsAuthorized = creatorLocalProjectBranchIsAuthorized({
3314
+ pendingBranchId: pendingBranch?.branchId,
3315
+ preserveOnlyBranchId: preservedBranch?.branchId
3316
+ });
3317
+ const disposition = workerProjectBranchDisposition({
3318
+ activeConfigured: Boolean(configured),
3319
+ locallyPendingCreated: pendingIncarnationIsAuthorized
3320
+ });
3321
+ if (configured && disposition === "active") return [configured];
3322
+ if (disposition === "preserve_only") return [];
2607
3323
  const retained = existing?.branches.find((branch) => branch.branchName === branchName);
2608
3324
  if (retained) return [retained];
2609
- if (!pending.has(projectBranchKey(layout.projectId, branchName))) return [];
3325
+ if (!pendingBranch?.branchId) return [];
2610
3326
  const branchPath = configuredProjectBranchPath(
2611
3327
  projectsRoot,
2612
3328
  { ...incoming, checkoutPathSegments: layout.checkoutPathSegments },
@@ -2615,6 +3331,7 @@ async function startWorker(options) {
2615
3331
  if (!hasProjectWorktree(branchPath)) return [];
2616
3332
  return [
2617
3333
  {
3334
+ branchId: pendingBranch.branchId,
2618
3335
  branchName,
2619
3336
  sourceBranchName: null,
2620
3337
  baseCommitHash: runGit(["rev-parse", "HEAD"], { cwd: branchPath })
@@ -2624,41 +3341,94 @@ async function startWorker(options) {
2624
3341
  return [{ ...incoming, checkoutPathSegments: [...layout.checkoutPathSegments], branches }];
2625
3342
  });
2626
3343
  };
2627
- const buildWorkspaceMounts = () => {
3344
+ const activeWorkspaceMutationTargets = () => [
3345
+ ...Array.from(activeProcesses.values(), ({ target }) => target),
3346
+ ...credentialBearingProcessGroupTargets.values(),
3347
+ ...workspaceSyncPriorityProcessTargets.values(),
3348
+ ...workspaceSyncPriorityOperationTargets.values(),
3349
+ ...Array.from(activePtys.values(), ({ target }) => target),
3350
+ ...workspaceSyncPriorityPtyTargets.values()
3351
+ ];
3352
+ const canonicalWorkspaceMutationIsActive = (targets) => targets.some((target) => target.type === "workspace" && target.rootProfile === "canonical_sync");
3353
+ const projectBranchMountActivityBusy = (projectId, branchName, branchPath) => {
3354
+ const activeTargets = activeWorkspaceMutationTargets();
3355
+ return projectConfigById.get(projectId)?.executionDisabled === true || hasProjectWorktree(branchPath) && projectWorktreeOperationInProgress(branchPath) || canonicalWorkspaceMutationIsActive(activeTargets) || projectBranchHasActiveWorkspaceTarget(projectId, branchName, activeTargets);
3356
+ };
3357
+ const projectBranchMountBusy = (projectId, branchName, branchPath) => !readyProjectIds.has(projectId) || projectBranchMountActivityBusy(projectId, branchName, branchPath);
3358
+ const buildWorkspaceMounts = (projects = projectConfigById.values()) => {
2628
3359
  const mounts = [];
2629
- for (const project of [...projectConfigById.values()].sort((left, right) => left.projectId.localeCompare(right.projectId))) {
3360
+ const creatorLocalIncarnations = new Map(
3361
+ projectWorkspaceState.locallyPendingCreatedBranches.flatMap(
3362
+ (branch) => branch.branchId ? [[projectBranchKey(branch.projectId, branch.branchName), branch.branchId]] : []
3363
+ )
3364
+ );
3365
+ const checkoutPathMoveBranches = new Set(
3366
+ projectWorkspaceState.pendingTreeDeletions.flatMap(
3367
+ (deletion) => deletion.kind === "project" && !deletion.projectDeleted ? deletion.branchNames.map((branchName) => projectBranchKey(deletion.projectId, branchName)) : []
3368
+ )
3369
+ );
3370
+ for (const project of [...projects].sort((left, right) => left.projectId.localeCompare(right.projectId))) {
2630
3371
  const projectRoot = configuredProjectRoot(projectsRoot, project);
2631
3372
  for (const branch of [...project.branches].sort((left, right) => left.branchName.localeCompare(right.branchName))) {
2632
3373
  const branchPath = configuredProjectBranchPath(projectsRoot, project, branch.branchName);
3374
+ const branchKey = projectBranchKey(project.projectId, branch.branchName);
3375
+ const creatorLocalIncarnation = creatorLocalIncarnations.get(branchKey) === branch.branchId;
3376
+ const preservesCheckoutPathMove = checkoutPathMoveBranches.has(branchKey);
2633
3377
  mounts.push({
2634
3378
  id: projectMountId(project.projectId, branch.branchName),
3379
+ hydrationIncarnationKey: branch.branchId,
2635
3380
  sourcePath: branchPath,
3381
+ durabilityRootPath: projectsRoot,
2636
3382
  workspaceRelativePath: workspaceProjectRelativePath(project.projectId, branch.branchName),
2637
3383
  sourceMode: "git",
2638
3384
  hydrateDeletionMode: "git",
2639
- busy: () => hasProjectWorktree(branchPath) && projectWorktreeOperationInProgress(branchPath)
3385
+ preserveLocalOnInitialOuterAbsence: creatorLocalIncarnation,
3386
+ preserveLocalOnHydrationBasisChange: preservesCheckoutPathMove,
3387
+ busy: () => projectBranchMountBusy(project.projectId, branch.branchName, branchPath),
3388
+ busyForRecovery: () => projectBranchMountActivityBusy(project.projectId, branch.branchName, branchPath)
2640
3389
  });
2641
3390
  mounts.push({
2642
3391
  id: projectPlanMountId(project.projectId, branch.branchName),
3392
+ hydrationIncarnationKey: branch.branchId,
2643
3393
  sourcePath: path.join(planRoot, project.projectId, ...branch.branchName.split("/")),
3394
+ durabilityRootPath: planRoot,
2644
3395
  workspaceRelativePath: workspacePlanRelativePath(project.projectId, branch.branchName),
2645
3396
  sourceMode: "all",
2646
- hydrateDeletionMode: "all"
3397
+ hydrateDeletionMode: "all",
3398
+ preserveLocalOnInitialOuterAbsence: creatorLocalIncarnation,
3399
+ preserveLocalOnHydrationBasisChange: preservesCheckoutPathMove,
3400
+ busy: () => projectBranchMountBusy(project.projectId, branch.branchName, branchPath),
3401
+ busyForRecovery: () => projectBranchMountActivityBusy(project.projectId, branch.branchName, branchPath)
2647
3402
  });
2648
3403
  }
2649
3404
  void projectRoot;
2650
3405
  }
2651
3406
  mounts.push({
2652
3407
  id: "workspace-plans",
3408
+ hydrationIncarnationKey: "workspace-plans-v1",
2653
3409
  sourcePath: path.join(planRoot, "workspace"),
3410
+ durabilityRootPath: planRoot,
2654
3411
  workspaceRelativePath: "workspace-plans",
2655
3412
  sourceMode: "all",
2656
- hydrateDeletionMode: "all"
3413
+ hydrateDeletionMode: "all",
3414
+ // Workspace-level agents receive this directory through R5D_PLANS_DIR.
3415
+ // Keep both projection and hydration away from it while any visible-
3416
+ // projects process, PTY, or built-in write/edit is active or reserved.
3417
+ busy: () => {
3418
+ const activeTargets = activeWorkspaceMutationTargets();
3419
+ return canonicalWorkspaceMutationIsActive(activeTargets) || hasActiveVisibleProjectsWorkspaceTarget(activeTargets);
3420
+ },
3421
+ busyForRecovery: () => {
3422
+ const activeTargets = activeWorkspaceMutationTargets();
3423
+ return canonicalWorkspaceMutationIsActive(activeTargets) || hasActiveVisibleProjectsWorkspaceTarget(activeTargets);
3424
+ }
2657
3425
  });
2658
3426
  for (const deletion of pendingMirrorDeletes.values()) {
2659
3427
  mounts.push({
2660
3428
  id: deletion.id,
3429
+ hydrationIncarnationKey: deletion.tombstoneId ?? `deleted:${deletion.projectId}:${deletion.branchName}`,
2661
3430
  sourcePath: deletion.sourcePath,
3431
+ durabilityRootPath: projectsRoot,
2662
3432
  workspaceRelativePath: deletion.workspaceRelativePath,
2663
3433
  sourceMode: "git",
2664
3434
  hydrateDeletionMode: "git",
@@ -2666,7 +3436,9 @@ async function startWorker(options) {
2666
3436
  });
2667
3437
  mounts.push({
2668
3438
  id: `${deletion.id}:plan`,
3439
+ hydrationIncarnationKey: `${deletion.tombstoneId ?? `deleted:${deletion.projectId}:${deletion.branchName}`}:plan`,
2669
3440
  sourcePath: deletion.planSourcePath,
3441
+ durabilityRootPath: planRoot,
2670
3442
  workspaceRelativePath: deletion.planWorkspaceRelativePath,
2671
3443
  sourceMode: "all",
2672
3444
  hydrateDeletionMode: "all",
@@ -2694,10 +3466,34 @@ async function startWorker(options) {
2694
3466
  const ensureConfiguredProjects = () => {
2695
3467
  for (const project of projectConfigById.values()) {
2696
3468
  validateProjectId(project.projectId);
3469
+ assertRepositoryTransitionState(project);
2697
3470
  for (const branch of project.branches) validateBranchName(branch.branchName);
3471
+ if (project.branches.length === 0) {
3472
+ readyProjectIds.delete(project.projectId);
3473
+ reconciledProjectConfigFingerprints.delete(project.projectId);
3474
+ continue;
3475
+ }
3476
+ if (project.executionDisabled) {
3477
+ readyProjectIds.delete(project.projectId);
3478
+ reconciledProjectConfigFingerprints.delete(project.projectId);
3479
+ for (const branch of project.branches) {
3480
+ pendingCheckouts.delete(projectBranchKey(project.projectId, branch.branchName));
3481
+ }
3482
+ continue;
3483
+ }
2698
3484
  const projectRoot = configuredProjectRoot(projectsRoot, project);
2699
3485
  const configFingerprint = projectWorktreeConfigurationFingerprint({ ...project, gitIdentity: workspaceGitIdentity });
2700
- const alreadyReady = readyProjectIds.has(project.projectId) && reconciledProjectConfigFingerprints.get(project.projectId) === configFingerprint && project.branches.every(({ branchName }) => hasProjectWorktree(configuredProjectBranchPath(projectsRoot, project, branchName)));
3486
+ const durableEnabledTransition = projectWorkspaceState.enabledRepositoryTransitions.find(
3487
+ ({ projectId }) => projectId === project.projectId
3488
+ );
3489
+ const transitionReseedRequired = enabledRepositoryTransitionRequiresReseed({
3490
+ project,
3491
+ lastEnabled: {
3492
+ known: Boolean(durableEnabledTransition),
3493
+ transitionId: durableEnabledTransition?.transitionId ?? null
3494
+ }
3495
+ });
3496
+ const alreadyReady = !transitionReseedRequired && readyProjectIds.has(project.projectId) && reconciledProjectConfigFingerprints.get(project.projectId) === configFingerprint && project.branches.every(({ branchName }) => hasProjectWorktree(configuredProjectBranchPath(projectsRoot, project, branchName)));
2701
3497
  if (alreadyReady) {
2702
3498
  for (const branch of project.branches) pendingCheckouts.delete(projectBranchKey(project.projectId, branch.branchName));
2703
3499
  continue;
@@ -2706,8 +3502,11 @@ async function startWorker(options) {
2706
3502
  const connection = projectConnection(project);
2707
3503
  const states = ensureProjectWorktrees({
2708
3504
  projectRoot,
3505
+ snapshotRoot: projectWorktreeSnapshotsRoot,
2709
3506
  primaryBranchName: primaryProjectBranch(project),
2710
3507
  branches: project.branches,
3508
+ defaultBranch: project.defaultBranch,
3509
+ ...transitionReseedRequired && project.repositoryTransitionId !== null ? { exactMirrorBranches: /* @__PURE__ */ new Set([primaryProjectBranch(project)]) } : {},
2711
3510
  originUrl: connection.originUrl,
2712
3511
  originCredentialUsername: connection.originCredentialUsername,
2713
3512
  mirrorUrl: connection.mirrorUrl,
@@ -2720,7 +3519,7 @@ async function startWorker(options) {
2720
3519
  for (const state of states) {
2721
3520
  pendingCheckouts.delete(projectBranchKey(project.projectId, state.branchName));
2722
3521
  const key = projectBranchKey(project.projectId, state.branchName);
2723
- if (!lastObservedProjectHeads.has(key)) lastObservedProjectHeads.set(key, state.mirrorHead);
3522
+ if (transitionReseedRequired || !lastObservedProjectHeads.has(key)) lastObservedProjectHeads.set(key, state.mirrorHead);
2724
3523
  if ((state.ahead ?? 0) > 0) {
2725
3524
  process.stderr.write(
2726
3525
  `[r5d-worker] ${project.projectPath}/${state.branchName} is ${state.ahead} commit(s) ahead of origin; switching workers may require publishing or reconciling those commits
@@ -2728,6 +3527,12 @@ async function startWorker(options) {
2728
3527
  );
2729
3528
  }
2730
3529
  }
3530
+ if (transitionReseedRequired) {
3531
+ projectWorkspaceState = projectWorkspaceStateStore.recordEnabledRepositoryTransition({
3532
+ projectId: project.projectId,
3533
+ transitionId: project.repositoryTransitionId
3534
+ });
3535
+ }
2731
3536
  } catch (error) {
2732
3537
  readyProjectIds.delete(project.projectId);
2733
3538
  reconciledProjectConfigFingerprints.delete(project.projectId);
@@ -2747,7 +3552,7 @@ async function startWorker(options) {
2747
3552
  const collectAheadOfOriginBranches = () => {
2748
3553
  const aheadBranches = [];
2749
3554
  for (const project of projectConfigById.values()) {
2750
- if (!readyProjectIds.has(project.projectId) || project.branches.length === 0) continue;
3555
+ if (project.executionDisabled || !readyProjectIds.has(project.projectId) || project.branches.length === 0) continue;
2751
3556
  const connection = projectConnection(project);
2752
3557
  const primaryPath = configuredProjectBranchPath(projectsRoot, project, primaryProjectBranch(project));
2753
3558
  if (!tryGit(
@@ -2769,7 +3574,11 @@ async function startWorker(options) {
2769
3574
  if (!hasProjectWorktree(branchPath)) continue;
2770
3575
  try {
2771
3576
  let ahead = 0;
2772
- const originRef = `refs/remotes/origin/${branch.branchName}`;
3577
+ const originRef = `refs/remotes/origin/${projectOriginBranchName({
3578
+ branchName: branch.branchName,
3579
+ primaryBranchName: primaryProjectBranch(project),
3580
+ defaultBranch: project.defaultBranch
3581
+ })}`;
2773
3582
  if (tryGit(["show-ref", "--verify", "--quiet", originRef], { cwd: branchPath })) {
2774
3583
  const counts = runGit(["rev-list", "--left-right", "--count", `${originRef}...HEAD`], { cwd: branchPath }).split(/\s+/).map(Number);
2775
3584
  ahead = Number.isSafeInteger(counts[1]) ? counts[1] : 0;
@@ -2790,18 +3599,30 @@ async function startWorker(options) {
2790
3599
  (left, right) => left.projectId.localeCompare(right.projectId) || left.branchName.localeCompare(right.branchName)
2791
3600
  );
2792
3601
  };
2793
- const pushChangedProjectHeads = (activeMountIds, publishedHead) => {
2794
- const active = new Set(activeMountIds);
3602
+ const captureProjectHeadsForWorkspacePublication = () => {
3603
+ const heads = /* @__PURE__ */ new Map();
2795
3604
  for (const project of projectConfigById.values()) {
2796
3605
  if (!readyProjectIds.has(project.projectId)) continue;
2797
- const changed = /* @__PURE__ */ new Set();
2798
3606
  for (const branch of project.branches) {
2799
- if (!active.has(projectMountId(project.projectId, branch.branchName))) continue;
2800
3607
  const branchPath = configuredProjectBranchPath(projectsRoot, project, branch.branchName);
2801
3608
  if (!hasProjectWorktree(branchPath)) continue;
2802
- const head = runGit(["rev-parse", "HEAD"], { cwd: branchPath });
3609
+ heads.set(projectMountId(project.projectId, branch.branchName), runGit(["rev-parse", "HEAD"], { cwd: branchPath }));
3610
+ }
3611
+ }
3612
+ return heads;
3613
+ };
3614
+ const pushChangedProjectHeads = (activeMountIds, publishedHead, publicationHeads, suppressedMountIds = /* @__PURE__ */ new Set()) => {
3615
+ const active = new Set(activeMountIds);
3616
+ for (const project of projectConfigById.values()) {
3617
+ if (!readyProjectIds.has(project.projectId) || !repositoryMirrorWritesAllowed(project)) continue;
3618
+ const changed = /* @__PURE__ */ new Map();
3619
+ for (const branch of project.branches) {
3620
+ const mountId = projectMountId(project.projectId, branch.branchName);
3621
+ if (!active.has(mountId) || suppressedMountIds.has(mountId)) continue;
3622
+ const head = publicationHeads.get(mountId);
3623
+ if (!head) throw new Error(`Missing pre-projection head for active project mount ${mountId}`);
2803
3624
  if (lastObservedProjectHeads.get(projectBranchKey(project.projectId, branch.branchName)) !== head) {
2804
- changed.add(branch.branchName);
3625
+ changed.set(branch.branchName, head);
2805
3626
  }
2806
3627
  }
2807
3628
  if (changed.size === 0) continue;
@@ -2812,10 +3633,12 @@ async function startWorker(options) {
2812
3633
  mirrorUrl: connection.mirrorUrl,
2813
3634
  credentialHelper: connection.credentialHelper,
2814
3635
  credentialUsername: connection.mirrorCredentialUsername,
2815
- onlyBranches: changed
3636
+ onlyBranches: new Set(changed.keys()),
3637
+ publicationHeads: changed
2816
3638
  });
3639
+ assertProjectMirrorHeadsPushed(results);
2817
3640
  for (const result of results) {
2818
- if (result.pushed) lastObservedProjectHeads.set(projectBranchKey(project.projectId, result.branchName), result.head);
3641
+ lastObservedProjectHeads.set(projectBranchKey(project.projectId, result.branchName), result.head);
2819
3642
  }
2820
3643
  }
2821
3644
  const publishedTombstoneIds = [
@@ -2833,6 +3656,7 @@ async function startWorker(options) {
2833
3656
  }
2834
3657
  for (const [key, deletion] of pendingMirrorDeletes) {
2835
3658
  if (!active.has(deletion.id)) continue;
3659
+ if (!repositoryMirrorWritesAllowed(projectConfigById.get(deletion.projectId))) continue;
2836
3660
  if (!deletion.projectDeleted) {
2837
3661
  deleteProjectMirrorBranch({
2838
3662
  gitDirectory: deletion.gitDirectory,
@@ -2855,73 +3679,105 @@ async function startWorker(options) {
2855
3679
  lastObservedProjectHeads.delete(key);
2856
3680
  }
2857
3681
  };
2858
- const refreshProjectHeadsAfterInboundWorkspace = () => {
3682
+ const observeProjectHeadsBeforeOuterWorkspace = (input) => {
3683
+ const bundles = [];
2859
3684
  for (const project of projectConfigById.values()) {
2860
- if (!readyProjectIds.has(project.projectId) || project.branches.length === 0) continue;
3685
+ if (project.executionDisabled || !readyProjectIds.has(project.projectId) || project.branches.length === 0) continue;
2861
3686
  try {
2862
3687
  const connection = projectConnection(project);
2863
- const refreshed = fastForwardProjectHeadsFromMirror({
2864
- projectRoot: configuredProjectRoot(projectsRoot, project),
2865
- primaryBranchName: primaryProjectBranch(project),
2866
- branchNames: project.branches.map(({ branchName }) => branchName),
2867
- mirrorUrl: connection.mirrorUrl,
2868
- credentialHelper: connection.credentialHelper,
2869
- credentialUsername: connection.mirrorCredentialUsername
3688
+ const projectRoot = configuredProjectRoot(projectsRoot, project);
3689
+ const primaryBranchName = primaryProjectBranch(project);
3690
+ bundles.push({
3691
+ projectId: project.projectId,
3692
+ projectRoot,
3693
+ primaryBranchName,
3694
+ observations: observeProjectMirrorHeads({
3695
+ projectRoot,
3696
+ primaryBranchName,
3697
+ branchNames: project.branches.map(({ branchName }) => branchName),
3698
+ mirrorUrl: connection.mirrorUrl,
3699
+ credentialHelper: connection.credentialHelper,
3700
+ credentialUsername: connection.mirrorCredentialUsername,
3701
+ allowNonFastForward: input.allowNonFastForward
3702
+ })
2870
3703
  });
3704
+ } catch (error) {
3705
+ if (input.failClosed) throw error;
3706
+ process.stderr.write(
3707
+ `[r5d-worker] failed to observe project mirror heads before workspace update: ${error instanceof Error ? error.message : String(error)}
3708
+ `
3709
+ );
3710
+ }
3711
+ }
3712
+ return bundles;
3713
+ };
3714
+ const applyObservedProjectHeadsAfterInboundWorkspace = (bundles, activeMountIds, requireObservedHeads) => {
3715
+ const active = new Set(activeMountIds);
3716
+ for (const bundle of bundles) {
3717
+ const project = projectConfigById.get(bundle.projectId);
3718
+ if (!project || project.executionDisabled || !readyProjectIds.has(project.projectId)) continue;
3719
+ const observations = bundle.observations.filter(({ branchName }) => active.has(projectMountId(project.projectId, branchName)));
3720
+ if (observations.length === 0) continue;
3721
+ try {
3722
+ const refreshed = applyObservedProjectMirrorHeads({
3723
+ projectRoot: bundle.projectRoot,
3724
+ primaryBranchName: bundle.primaryBranchName,
3725
+ observations,
3726
+ branchMutationAllowed: (branchName, checkoutPath) => !projectBranchMountBusy(project.projectId, branchName, checkoutPath)
3727
+ });
3728
+ if (requireObservedHeads) {
3729
+ for (const observation of observations) {
3730
+ if (!observation.shouldMove || !observation.mirrorHead) continue;
3731
+ const checkoutPath = configuredProjectBranchPath(projectsRoot, project, observation.branchName);
3732
+ const currentHead = runGit(["rev-parse", "HEAD"], { cwd: checkoutPath });
3733
+ if (currentHead !== observation.mirrorHead) {
3734
+ throw new Error(
3735
+ `Project ${project.projectId}/${observation.branchName} did not apply observed hidden mirror head ${observation.mirrorHead}`
3736
+ );
3737
+ }
3738
+ }
3739
+ }
2871
3740
  for (const state of refreshed) {
2872
3741
  lastObservedProjectHeads.set(
2873
3742
  projectBranchKey(project.projectId, state.branchName),
2874
- state.fastForwarded && state.mirrorHead ? state.mirrorHead : state.previousHead
3743
+ state.moved && state.mirrorHead ? state.mirrorHead : state.previousHead
2875
3744
  );
2876
3745
  }
2877
3746
  } catch (error) {
3747
+ if (requireObservedHeads) throw error;
2878
3748
  process.stderr.write(
2879
- `[r5d-worker] failed to refresh project heads after workspace update: ${error instanceof Error ? error.message : String(error)}
3749
+ `[r5d-worker] failed to apply observed project heads after workspace update: ${error instanceof Error ? error.message : String(error)}
2880
3750
  `
2881
3751
  );
2882
3752
  }
2883
3753
  }
2884
3754
  };
2885
- const reseedProjectHeadsAfterWorkspaceReset = () => {
2886
- for (const project of projectConfigById.values()) {
2887
- if (!readyProjectIds.has(project.projectId)) continue;
2888
- const connection = projectConnection(project);
2889
- const states = ensureProjectWorktrees({
2890
- projectRoot: configuredProjectRoot(projectsRoot, project),
2891
- primaryBranchName: primaryProjectBranch(project),
2892
- branches: project.branches,
2893
- originUrl: connection.originUrl,
2894
- originCredentialUsername: connection.originCredentialUsername,
2895
- mirrorUrl: connection.mirrorUrl,
2896
- mirrorCredentialUsername: connection.mirrorCredentialUsername,
2897
- credentialHelper: connection.credentialHelper,
2898
- gitIdentity: workspaceGitIdentity
2899
- });
2900
- for (const state of states) {
2901
- lastObservedProjectHeads.set(projectBranchKey(project.projectId, state.branchName), state.head);
2902
- }
2903
- }
3755
+ const mapWorkspaceGitResult = (attemptId, trigger, result) => {
3756
+ const activeProjectBranchPublications = activeProjectBranchPublicationEvidence([...projectConfigById.values()], result.activeMountIds);
3757
+ return {
3758
+ type: "workspace_sync",
3759
+ attemptId,
3760
+ workerLabel: label,
3761
+ trigger,
3762
+ outcome: result.outcome === "pushed" ? "published" : result.outcome,
3763
+ startingHead: result.startingHead,
3764
+ ...result.localHead ? { localHead: result.localHead } : {},
3765
+ ...result.publishedHead ? { publishedHead: result.publishedHead } : {},
3766
+ rebaseCount: result.rebaseCount,
3767
+ diffSizeBytes: result.diffSizeBytes,
3768
+ gitStatus: workspaceGitStatus(),
3769
+ affectedProjects: affectedProjects(result.affectedPaths),
3770
+ affectedPaths: result.affectedPaths,
3771
+ activeMountIds: result.activeMountIds,
3772
+ skippedMountIds: result.skippedMountIds,
3773
+ activeProjectBranchPublications,
3774
+ discardedPaths: [],
3775
+ localChangesDiscarded: false,
3776
+ ...result.conflictPaths ? { conflictPaths: result.conflictPaths } : {},
3777
+ ...result.conflictSnapshotRefs ? { conflictSnapshotRefs: result.conflictSnapshotRefs } : {},
3778
+ ...result.error ? { error: result.error } : {}
3779
+ };
2904
3780
  };
2905
- const mapWorkspaceGitResult = (attemptId, trigger, result) => ({
2906
- type: "workspace_sync",
2907
- attemptId,
2908
- workerLabel: label,
2909
- trigger,
2910
- outcome: result.outcome === "pushed" ? "published" : result.outcome,
2911
- startingHead: result.startingHead,
2912
- ...result.localHead ? { localHead: result.localHead } : {},
2913
- ...result.publishedHead ? { publishedHead: result.publishedHead } : {},
2914
- rebaseCount: result.rebaseCount,
2915
- diffSizeBytes: result.diffSizeBytes,
2916
- gitStatus: workspaceGitStatus(),
2917
- affectedProjects: affectedProjects(result.affectedPaths),
2918
- affectedPaths: result.affectedPaths,
2919
- discardedPaths: [],
2920
- localChangesDiscarded: false,
2921
- ...result.conflictPaths ? { conflictPaths: result.conflictPaths } : {},
2922
- ...result.conflictSnapshotRefs ? { conflictSnapshotRefs: result.conflictSnapshotRefs } : {},
2923
- ...result.error ? { error: result.error } : {}
2924
- });
2925
3781
  const failedWorkspaceSyncResult = (attemptId, trigger, error) => ({
2926
3782
  type: "workspace_sync",
2927
3783
  attemptId,
@@ -2945,6 +3801,16 @@ async function startWorker(options) {
2945
3801
  }
2946
3802
  const mounts = buildWorkspaceMounts();
2947
3803
  if (input.resetToCanonical) {
3804
+ const resetHasBusyLiveMount = mounts.some(
3805
+ (mount) => !(mount.deleteWhenSourceMissing && !fs.existsSync(mount.sourcePath)) && mount.busy?.()
3806
+ );
3807
+ if (resetHasBusyLiveMount) {
3808
+ throw new Error("Workspace reset cannot observe project mirror heads while a live mount is busy; remediation remains active");
3809
+ }
3810
+ const observedProjectHeads2 = observeProjectHeadsBeforeOuterWorkspace({
3811
+ allowNonFastForward: true,
3812
+ failClosed: true
3813
+ });
2948
3814
  const reset = resetWorkspaceGit({
2949
3815
  workspacePath: workspaceShadowRoot,
2950
3816
  remoteUrl: workspaceRemoteUrl,
@@ -2953,7 +3819,7 @@ async function startWorker(options) {
2953
3819
  gitIdentity: workspaceGitIdentity,
2954
3820
  mounts
2955
3821
  });
2956
- reseedProjectHeadsAfterWorkspaceReset();
3822
+ applyObservedProjectHeadsAfterInboundWorkspace(observedProjectHeads2, reset.activeMountIds, true);
2957
3823
  return {
2958
3824
  type: "workspace_sync",
2959
3825
  attemptId: input.attemptId,
@@ -2968,11 +3834,23 @@ async function startWorker(options) {
2968
3834
  gitStatus: workspaceGitStatus(),
2969
3835
  affectedProjects: affectedProjects(reset.discardedPaths),
2970
3836
  affectedPaths: reset.discardedPaths,
3837
+ activeMountIds: reset.activeMountIds,
3838
+ skippedMountIds: reset.skippedMountIds,
2971
3839
  discardedPaths: reset.discardedPaths,
2972
3840
  localChangesDiscarded: reset.discardedPaths.length > 0 || reset.startingHead !== reset.remoteHead
2973
3841
  };
2974
3842
  }
2975
3843
  const outerRemediation = input.trigger.type === "remediation" || input.trigger.type === "remediation_confirm";
3844
+ const ordinaryCycleHasBusyLiveMount = mounts.some(
3845
+ (mount) => !(mount.deleteWhenSourceMissing && !fs.existsSync(mount.sourcePath)) && mount.busy?.()
3846
+ );
3847
+ const observedProjectHeads = outerRemediation || ordinaryCycleHasBusyLiveMount ? [] : observeProjectHeadsBeforeOuterWorkspace({ allowNonFastForward: false, failClosed: false });
3848
+ const inboundMoveMountIds = new Set(
3849
+ observedProjectHeads.flatMap(
3850
+ ({ projectId, observations }) => observations.filter(({ shouldMove }) => shouldMove).map(({ branchName }) => projectMountId(projectId, branchName))
3851
+ )
3852
+ );
3853
+ const publicationHeads = outerRemediation ? /* @__PURE__ */ new Map() : captureProjectHeadsForWorkspacePublication();
2976
3854
  const result = await synchronizeWorkspaceGit({
2977
3855
  attemptId: input.attemptId,
2978
3856
  workerLabel: label,
@@ -2985,13 +3863,12 @@ async function startWorker(options) {
2985
3863
  commitDetail: input.confirmationReason ?? input.trigger.detail,
2986
3864
  allowLargeDiff: input.confirmedLargeDiff,
2987
3865
  skipMountMirror: outerRemediation,
2988
- skipMountHydration: outerRemediation,
2989
3866
  afterWorkspacePublished: outerRemediation ? void 0 : ({ activeMountIds, publishedHead }) => {
2990
- pushChangedProjectHeads(activeMountIds, publishedHead);
3867
+ pushChangedProjectHeads(activeMountIds, publishedHead, publicationHeads, inboundMoveMountIds);
2991
3868
  }
2992
3869
  });
2993
3870
  if (["no_change", "updated", "pushed"].includes(result.outcome) && !outerRemediation) {
2994
- refreshProjectHeadsAfterInboundWorkspace();
3871
+ applyObservedProjectHeadsAfterInboundWorkspace(observedProjectHeads, result.activeMountIds, false);
2995
3872
  }
2996
3873
  return mapWorkspaceGitResult(input.attemptId, input.trigger, result);
2997
3874
  };
@@ -3012,6 +3889,12 @@ async function startWorker(options) {
3012
3889
  const runWorkspaceSync = async (input) => {
3013
3890
  const attemptId = input.attemptId ?? crypto.randomUUID();
3014
3891
  const requestedAt = Date.now();
3892
+ if (!input.requestId && input.sendResult !== false) {
3893
+ if (currentWorkerSocket !== ws || ws.readyState !== WebSocket.OPEN) {
3894
+ throw new Error("Cannot start autonomous workspace synchronization without an open worker control socket");
3895
+ }
3896
+ ws.send(JSON.stringify({ type: "workspace_sync_started", attemptId }));
3897
+ }
3015
3898
  let queueEnteredAt = requestedAt;
3016
3899
  let syncStartedAt = requestedAt;
3017
3900
  workspaceSyncRequestsInFlight += 1;
@@ -3029,6 +3912,28 @@ async function startWorker(options) {
3029
3912
  resetToCanonical: input.resetToCanonical
3030
3913
  });
3031
3914
  } catch (error) {
3915
+ let hydrationCurrent = input.resetToCanonical !== true;
3916
+ if (hydrationCurrent) {
3917
+ try {
3918
+ hydrationCurrent = workspaceGitHydrationIsCurrent(workspaceShadowRoot, buildWorkspaceMounts());
3919
+ } catch {
3920
+ hydrationCurrent = false;
3921
+ }
3922
+ }
3923
+ if (!hydrationCurrent) {
3924
+ process.stderr.write(
3925
+ `[r5d-worker] workspace synchronization failed with an incompletely hydrated visible tree; exiting for exact recovery: ${error instanceof Error ? error.message : String(error)}
3926
+ `
3927
+ );
3928
+ }
3929
+ fenceUnsafeWorkspaceSyncFailure({
3930
+ hydrationCurrent,
3931
+ invalidateExecution: () => {
3932
+ workspaceConfigured = false;
3933
+ advanceWorkerAdmissionGeneration();
3934
+ },
3935
+ exitCode: 1
3936
+ });
3032
3937
  return failedWorkspaceSyncResult(attemptId, input.trigger, error);
3033
3938
  }
3034
3939
  });
@@ -3050,7 +3955,13 @@ async function startWorker(options) {
3050
3955
  };
3051
3956
  const scheduleAutomaticWorkspaceSync = (trigger, delayMs = WORKSPACE_GIT_QUIET_MS) => {
3052
3957
  pendingAutomaticTrigger = trigger;
3053
- if (!workspaceConfigured || activeWorkspaceIncidentId || currentWorkerSocket !== ws || ws.readyState !== WebSocket.OPEN || automaticSyncInFlight) {
3958
+ const pendingBranchDeferral = () => pendingCreatedBranchAutomaticSyncDeferral(
3959
+ projectWorkspaceState.locallyPendingCreatedBranches,
3960
+ activeWorkspaceMutationTargets(),
3961
+ pendingCreatedBranchPublicationNotBefore
3962
+ );
3963
+ const initialDeferral = pendingBranchDeferral();
3964
+ if (!workspaceConfigured || activeWorkspaceIncidentId || currentWorkerSocket !== ws || ws.readyState !== WebSocket.OPEN || automaticSyncInFlight || initialDeferral?.kind === "active_target") {
3054
3965
  return;
3055
3966
  }
3056
3967
  if (workspaceAutomaticTimer) clearTimeout(workspaceAutomaticTimer);
@@ -3058,13 +3969,25 @@ async function startWorker(options) {
3058
3969
  () => {
3059
3970
  workspaceAutomaticTimer = void 0;
3060
3971
  const scheduledTrigger = pendingAutomaticTrigger;
3061
- pendingAutomaticTrigger = void 0;
3062
3972
  if (!scheduledTrigger || !workspaceConfigured || activeWorkspaceIncidentId || currentWorkerSocket !== ws) return;
3973
+ const currentDeferral = pendingBranchDeferral();
3974
+ if (currentDeferral?.kind === "active_target") return;
3975
+ if (currentDeferral?.kind === "creation_grace") {
3976
+ scheduleAutomaticWorkspaceSync(scheduledTrigger, currentDeferral.retryAfterMs);
3977
+ return;
3978
+ }
3979
+ pendingAutomaticTrigger = void 0;
3063
3980
  automaticSyncInFlight = true;
3064
3981
  void runWorkspaceSync({ trigger: scheduledTrigger }).then((result) => {
3065
3982
  if (result.outcome === "failed") {
3066
3983
  pendingAutomaticTrigger = scheduledTrigger;
3067
3984
  }
3985
+ }).catch((error) => {
3986
+ pendingAutomaticTrigger = scheduledTrigger;
3987
+ process.stderr.write(
3988
+ `[r5d-worker] failed to start automatic workspace sync: ${error instanceof Error ? error.message : String(error)}
3989
+ `
3990
+ );
3068
3991
  }).finally(() => {
3069
3992
  automaticSyncInFlight = false;
3070
3993
  heartbeatBusyGrace = grantWorkerHeartbeatBusyGrace(lastServerHeartbeatAt, heartbeatBusyGrace);
@@ -3073,7 +3996,7 @@ async function startWorker(options) {
3073
3996
  }
3074
3997
  });
3075
3998
  },
3076
- Math.max(0, delayMs)
3999
+ Math.max(0, delayMs, initialDeferral?.kind === "creation_grace" ? initialDeferral.retryAfterMs : 0)
3077
4000
  );
3078
4001
  workspaceAutomaticTimer.unref();
3079
4002
  };
@@ -3081,32 +4004,190 @@ async function startWorker(options) {
3081
4004
  scheduleAutomaticWorkspaceSync(trigger, force ? 0 : WORKSPACE_GIT_QUIET_MS);
3082
4005
  };
3083
4006
  const targetMayMutateVisibleWorkspace = (target) => target.type === "project" || target.rootProfile === "visible_projects";
3084
- const resolveMessageTarget = (target) => resolveWorkerSessionTarget({
3085
- target,
3086
- projectsRoot,
3087
- workspaceShadowRoot,
3088
- projectConfigById
3089
- });
4007
+ const resolveMessageTarget = (target) => {
4008
+ if (!workspaceConfigured) throw new Error("Worker workspace configuration has not completed successfully");
4009
+ if (target.type === "project" && !readyProjectIds.has(target.projectId)) {
4010
+ throw new Error(`Project ${target.projectId} is not ready on this worker`);
4011
+ }
4012
+ if (target.type === "workspace" && target.rootProfile === "visible_projects" && [...projectConfigById.keys()].some((projectId) => !readyProjectIds.has(projectId))) {
4013
+ throw new Error("Visible project execution is unavailable while project configuration is incomplete");
4014
+ }
4015
+ return resolveWorkerSessionTarget({
4016
+ target,
4017
+ projectsRoot,
4018
+ workspaceShadowRoot,
4019
+ projectConfigById
4020
+ });
4021
+ };
3090
4022
  const configureWorkerWorkspace = async (message) => {
4023
+ const incomingCredentialGenerationFingerprint = credentialGenerationFingerprint({
4024
+ ...message,
4025
+ workerBaseUrl: baseUrl,
4026
+ workerAuthHeader: bearerAuthHeader
4027
+ });
4028
+ const credentialTransitionPhase = credentialGenerationTransitionPhase({
4029
+ committedFingerprint: configuredCredentialGenerationFingerprint,
4030
+ pendingFingerprintAtProcessStart: pendingCredentialGenerationIntentAtProcessStart?.fingerprint ?? null,
4031
+ incomingFingerprint: incomingCredentialGenerationFingerprint
4032
+ });
4033
+ const credentialReapStatus = credentialReapContractStatus();
4034
+ const verifiedCredentialReapContractNow = credentialReapStatus === "verified_systemd";
4035
+ const credentialRestartIsContained = credentialGenerationRestartIsContained({
4036
+ systemdContract: verifiedCredentialReapContractNow,
4037
+ pendingBoundary: pendingCredentialGenerationIntentAtProcessStart?.boundary ?? null,
4038
+ currentSystemdInvocationId: systemdInvocationIdentifierAtProcessStart,
4039
+ currentBootSessionId: bootSessionIdentifierAtProcessStart,
4040
+ currentMachineId: machineIdentifierAtProcessStart
4041
+ });
4042
+ let trackedChildTerminationProven = true;
4043
+ if (credentialTransitionPhase !== "current") {
4044
+ workspaceConfigured = false;
4045
+ githubCredential = null;
4046
+ advanceWorkerAdmissionGeneration();
4047
+ try {
4048
+ await terminateActiveCredentialBearingChildren();
4049
+ } catch (error) {
4050
+ trackedChildTerminationProven = false;
4051
+ process.stderr.write(
4052
+ `[r5d-worker] tracked credential-bearing child termination was incomplete at the credential transition boundary: ${error instanceof Error ? error.message : String(error)}
4053
+ `
4054
+ );
4055
+ }
4056
+ }
4057
+ const initialBootstrapAuthorized = initialCredentialBootstrapIsSafe({
4058
+ transitionPhase: credentialTransitionPhase,
4059
+ committedFingerprint: configuredCredentialGenerationFingerprint,
4060
+ pendingFingerprintAtProcessStart: pendingCredentialGenerationIntentAtProcessStart?.fingerprint ?? null,
4061
+ bootstrapFingerprintAtProcessStart: bootstrapCredentialGenerationFingerprintAtProcessStart,
4062
+ incomingFingerprint: incomingCredentialGenerationFingerprint,
4063
+ credentialArtifactsProvenAbsentAtProcessStart: initialCredentialBootstrapProvenEmpty,
4064
+ trackedChildTerminationProven,
4065
+ activeProcessCount: activeProcesses.size,
4066
+ credentialProcessGroupCount: credentialBearingProcessGroups.size,
4067
+ activePtyCount: activePtys.size
4068
+ });
4069
+ const staleIntentCleanupAuthorized = credentialTransitionPhase === "current" && (pendingCredentialGenerationIntentAtProcessStart !== null || bootstrapCredentialGenerationFingerprintAtProcessStart !== null);
4070
+ const credentialPublicationPreauthorized = credentialTransitionPhase === "publish_from_clean_restart" && credentialRestartIsContained || initialBootstrapAuthorized || staleIntentCleanupAuthorized;
4071
+ if (initialBootstrapAuthorized && bootstrapCredentialGenerationFingerprintAtProcessStart === null) {
4072
+ try {
4073
+ const bootstrap = preparePrivateAuthFileGeneration([
4074
+ { filePath: bootstrapCredentialGenerationPath(), content: `${incomingCredentialGenerationFingerprint}
4075
+ ` }
4076
+ ]);
4077
+ bootstrap.commit();
4078
+ bootstrapCredentialGenerationFingerprintAtProcessStart = incomingCredentialGenerationFingerprint;
4079
+ } catch (error) {
4080
+ const wrapped = error instanceof RegistryAuthConfigurationError ? error : new RegistryAuthConfigurationError(error);
4081
+ return {
4082
+ result: failedWorkspaceSyncResult(crypto.randomUUID(), { type: "connect" }, wrapped),
4083
+ pending: [],
4084
+ aheadOfOriginBranches: []
4085
+ };
4086
+ }
4087
+ }
4088
+ if (credentialTransitionPhase === "stage_pending_restart" && !initialBootstrapAuthorized) {
4089
+ if (credentialReapStatus === "systemd_unverified") {
4090
+ process.stderr.write(
4091
+ "[r5d-worker] this process is inside r5d-worker.service but its credential-reaping contract could not be verified; no pending credential generation was written and the service may retry safely\n"
4092
+ );
4093
+ requireCredentialGenerationRestart(true, void 0, 1);
4094
+ }
4095
+ const boundary = pendingCredentialGenerationBoundary({
4096
+ systemdContract: verifiedCredentialReapContractNow,
4097
+ systemdInvocationId: systemdInvocationIdentifierAtProcessStart,
4098
+ bootSessionId: bootSessionIdentifierAtProcessStart,
4099
+ machineId: machineIdentifierAtProcessStart
4100
+ });
4101
+ if (!credentialGenerationBoundaryIsComplete(boundary)) {
4102
+ process.stderr.write(
4103
+ process.platform === "win32" ? "[r5d-worker] credential rotation is unavailable for a direct Windows worker because no authoritative per-boot identifier is supported; move it behind the verified systemd contract or reprovision a clean worker host\n" : "[r5d-worker] could not attest this worker's stable machine and boot identifiers; no pending credential generation was written\n"
4104
+ );
4105
+ requireCredentialGenerationRestart(true, void 0, 1);
4106
+ }
4107
+ try {
4108
+ const pending = preparePrivateAuthFileGeneration([
4109
+ {
4110
+ filePath: pendingCredentialGenerationPath(),
4111
+ content: pendingCredentialGenerationIntentContent({
4112
+ fingerprint: incomingCredentialGenerationFingerprint,
4113
+ boundary
4114
+ })
4115
+ }
4116
+ ]);
4117
+ pending.commit();
4118
+ } catch (error) {
4119
+ process.stderr.write(
4120
+ `[r5d-worker] could not stage pending credential generation before fatal restart: ${error instanceof Error ? error.message : String(error)}
4121
+ `
4122
+ );
4123
+ requireCredentialGenerationRestart(true, void 0, 1);
4124
+ }
4125
+ process.stderr.write(
4126
+ verifiedCredentialReapContractNow ? "[r5d-worker] credential transition staged without secrets; restarting through the service cgroup boundary\n" : "[r5d-worker] credential transition staged without secrets; reboot this worker host or VM before starting r5d-worker again\n"
4127
+ );
4128
+ requireCredentialGenerationRestart(true, void 0, WORKER_CREDENTIAL_RESTART_EXIT_CODE);
4129
+ }
4130
+ if (credentialTransitionPhase !== "current" && !trackedChildTerminationProven) {
4131
+ requireCredentialGenerationRestart(true, void 0, 1);
4132
+ }
4133
+ if (credentialTransitionPhase === "publish_from_clean_restart" && !credentialRestartIsContained) {
4134
+ const pendingBoundary = pendingCredentialGenerationIntentAtProcessStart?.boundary ?? null;
4135
+ const detail = pendingBoundary?.machineId === null || machineIdentifierAtProcessStart === null ? "credential transition cannot prove it is still on the same worker host; keep the credential directory host-local and ensure the OS machine identifier is available" : pendingBoundary?.kind === "systemd" ? "credential transition is waiting for a new verified r5d-worker.service invocation on the same host" : pendingBoundary?.bootSessionId === null || bootSessionIdentifierAtProcessStart === null ? "credential transition cannot prove a portable whole-host restart; ensure its kernel boot identifier is available" : "credential transition is still in the boot session that staged it";
4136
+ process.stderr.write(
4137
+ `[r5d-worker] ${detail}; no new credential bytes were published. ${pendingBoundary?.kind === "boot" ? "Reboot the entire worker host or VM before starting r5d-worker again." : "The verified systemd unit must start a new invocation before retrying."}
4138
+ `
4139
+ );
4140
+ requireCredentialGenerationRestart(true, void 0, WORKER_CREDENTIAL_RESTART_EXIT_CODE);
4141
+ }
4142
+ if (credentialPublicationPreauthorized) workspaceConfigured = false;
3091
4143
  workspaceSyncRequestsInFlight += 1;
3092
4144
  try {
3093
4145
  return await workspaceSyncSingleFlight.runExclusive(async () => {
4146
+ for (const project of message.projects) assertRepositoryTransitionState(project);
4147
+ const busyConfigurationChanges = busyProjectConfigurationChangeIds({
4148
+ currentProjects: [...projectConfigById.values()],
4149
+ incomingProjects: message.projects,
4150
+ activeTargets: activeWorkspaceMutationTargets(),
4151
+ currentFingerprint: (project) => projectWorktreeConfigurationFingerprint({ ...project, gitIdentity: workspaceGitIdentity }),
4152
+ incomingFingerprint: (project) => projectWorktreeConfigurationFingerprint({ ...project, gitIdentity: message.gitIdentity }),
4153
+ currentProjectReady: (project) => {
4154
+ const fingerprint = projectWorktreeConfigurationFingerprint({ ...project, gitIdentity: workspaceGitIdentity });
4155
+ return readyProjectIds.has(project.projectId) && reconciledProjectConfigFingerprints.get(project.projectId) === fingerprint && project.branches.every(({ branchName }) => hasProjectWorktree(configuredProjectBranchPath(projectsRoot, project, branchName)));
4156
+ }
4157
+ });
4158
+ if (busyConfigurationChanges.length > 0) {
4159
+ throw new ProjectWorkspaceConfigurationDeferredError(
4160
+ `Project workspace configuration deferred while visible checkout activity is active: ${busyConfigurationChanges.join(", ")}`
4161
+ );
4162
+ }
3094
4163
  workspaceConfigured = false;
4164
+ const preserveOnlyBranches = message.projects.flatMap(
4165
+ (project) => project.preserveOnlyBranches.map(({ branchId, branchName }) => ({ branchId, projectId: project.projectId, branchName }))
4166
+ );
3095
4167
  projectWorkspaceState = projectWorkspaceStateStore.reconcile({
3096
- desiredProjects: message.projects
4168
+ desiredProjects: message.projects,
4169
+ preserveOnlyBranches
3097
4170
  });
3098
- const requiredCredentialStorePaths = /* @__PURE__ */ new Set();
4171
+ const stillPendingCreatedBranches = new Map(
4172
+ projectWorkspaceState.locallyPendingCreatedBranches.map((branch) => [
4173
+ pendingCreatedBranchKey(branch.projectId, branch.branchName),
4174
+ branch
4175
+ ])
4176
+ );
4177
+ for (const key of pendingCreatedBranchPublicationNotBefore.keys()) {
4178
+ if (!stillPendingCreatedBranches.has(key)) pendingCreatedBranchPublicationNotBefore.delete(key);
4179
+ }
4180
+ const desiredCredentialStoreFiles = /* @__PURE__ */ new Map();
3099
4181
  const workspaceStorePath = credentialStorePathForWorkspace(message.workspaceRemoteUrl);
3100
- requiredCredentialStorePaths.add(workspaceStorePath);
3101
4182
  const nextWorkspaceCredentialHelper = credentialHelperForRemotes(
3102
4183
  [{ remoteUrl: message.workspaceRemoteUrl, authHeader: bearerAuthHeader }],
3103
- workspaceStorePath
4184
+ workspaceStorePath,
4185
+ desiredCredentialStoreFiles
3104
4186
  );
3105
4187
  if (!nextWorkspaceCredentialHelper) throw new Error("Workspace credentials are unavailable");
3106
4188
  const desiredProjectConfigById = new Map(message.projects.map((project) => [project.projectId, project]));
3107
4189
  for (const project of message.projects) {
3108
- requiredCredentialStorePaths.add(credentialStorePathForProject(project.projectId));
3109
- projectConnection(project);
4190
+ projectConnection(project, desiredCredentialStoreFiles);
3110
4191
  }
3111
4192
  const pendingCredentialProjectIds = /* @__PURE__ */ new Set([
3112
4193
  ...[...pendingMirrorDeletes.values()].map(({ projectId }) => projectId),
@@ -3115,30 +4196,81 @@ async function startWorker(options) {
3115
4196
  ]);
3116
4197
  for (const projectId of pendingCredentialProjectIds) {
3117
4198
  const desiredProject = desiredProjectConfigById.get(projectId);
3118
- if (desiredProject) projectConnection(desiredProject);
3119
- else projectMirrorConnection(projectId);
3120
- requiredCredentialStorePaths.add(credentialStorePathForProject(projectId));
4199
+ if (desiredProject) projectConnection(desiredProject, desiredCredentialStoreFiles);
4200
+ else projectMirrorConnection(projectId, desiredCredentialStoreFiles);
3121
4201
  }
3122
4202
  for (const deletion of pendingMirrorDeletes.values()) {
3123
4203
  const desiredProject = desiredProjectConfigById.get(deletion.projectId);
3124
- const connection = desiredProject ? projectConnection(desiredProject) : projectMirrorConnection(deletion.projectId);
4204
+ const connection = desiredProject ? projectConnection(desiredProject, desiredCredentialStoreFiles) : projectMirrorConnection(deletion.projectId, desiredCredentialStoreFiles);
3125
4205
  deletion.mirrorUrl = connection.mirrorUrl;
3126
4206
  deletion.credentialHelper = connection.credentialHelper;
3127
4207
  deletion.credentialUsername = connection.mirrorCredentialUsername;
3128
4208
  }
3129
- pruneCredentialStoreFiles(requiredCredentialStorePaths);
3130
- configureGitHubAuth(message.githubCredential);
4209
+ let preparedAuthGeneration;
4210
+ try {
4211
+ preparedAuthGeneration = preparePrivateAuthFileGeneration([
4212
+ ...planCredentialStoreGeneration(desiredCredentialStoreFiles),
4213
+ ...planGitHubRegistryAuthFiles([dockerConfigPath(), containersAuthPath()], message.githubCredential),
4214
+ { filePath: legacySharedCredentialStorePath(), content: null, allowSymlinkRemoval: true },
4215
+ { filePath: credentialGenerationReceiptPath(), content: `${incomingCredentialGenerationFingerprint}
4216
+ ` },
4217
+ { filePath: pendingCredentialGenerationPath(), content: null },
4218
+ { filePath: bootstrapCredentialGenerationPath(), content: null }
4219
+ ]);
4220
+ } catch (error) {
4221
+ throw error instanceof RegistryAuthConfigurationError ? error : new RegistryAuthConfigurationError(error);
4222
+ }
4223
+ await commitCredentialGeneration(
4224
+ preparedAuthGeneration,
4225
+ message.githubCredential,
4226
+ terminateActiveCredentialBearingChildren,
4227
+ void 0,
4228
+ credentialPublicationPreauthorized
4229
+ );
4230
+ configuredCredentialGenerationFingerprint = incomingCredentialGenerationFingerprint;
4231
+ pendingCredentialGenerationIntentAtProcessStart = null;
4232
+ bootstrapCredentialGenerationFingerprintAtProcessStart = null;
3131
4233
  visibleGitIdentity = message.gitIdentity;
3132
4234
  workspaceGitIdentity = message.gitIdentity;
3133
4235
  workspaceRemoteUrl = message.workspaceRemoteUrl;
3134
4236
  workspaceCredentialHelper = nextWorkspaceCredentialHelper;
3135
4237
  workspaceCredentialUsername = workerCredentialUsername;
3136
- pruneAuthoritativelyDesiredBranchDeletions(pendingMirrorDeletes, message.projects);
4238
+ pruneAuthoritativelyDesiredBranchDeletions(pendingMirrorDeletes, projectWorkspaceState.desiredProjects);
3137
4239
  const effectiveProjects = effectiveWorkerProjects(message.projects).map((project) => ({
3138
4240
  ...project,
3139
4241
  branches: project.branches.filter(({ branchName }) => !pendingMirrorDeletes.has(projectBranchKey(project.projectId, branchName)))
3140
4242
  }));
3141
4243
  const nextProjectConfigById = new Map(effectiveProjects.map((project) => [project.projectId, project]));
4244
+ ensureWorkspaceGitClone({
4245
+ workspacePath: workspaceShadowRoot,
4246
+ remoteUrl: message.workspaceRemoteUrl,
4247
+ credentialHelper: nextWorkspaceCredentialHelper,
4248
+ credentialUsername: workerCredentialUsername,
4249
+ gitIdentity: message.gitIdentity
4250
+ });
4251
+ const recoverySourceOverrides = /* @__PURE__ */ new Map();
4252
+ for (const deletion of projectWorkspaceState.pendingTreeDeletions) {
4253
+ if (deletion.kind !== "project" || deletion.projectDeleted) continue;
4254
+ for (const branchName of deletion.branchNames) {
4255
+ const oldBranchPath = path.join(deletion.managedPath, ...branchName.split("/"));
4256
+ if (fs.existsSync(oldBranchPath)) recoverySourceOverrides.set(projectMountId(deletion.projectId, branchName), oldBranchPath);
4257
+ }
4258
+ }
4259
+ const recoveryMounts = buildWorkspaceMounts(effectiveProjects).map((mount) => ({
4260
+ ...mount,
4261
+ sourcePath: recoverySourceOverrides.get(mount.id) ?? mount.sourcePath,
4262
+ busy: mount.busyForRecovery ?? mount.busy
4263
+ }));
4264
+ recoverWorkspaceGitHydration(workspaceShadowRoot, recoveryMounts);
4265
+ for (const project of message.projects) {
4266
+ for (const { branchName } of project.preserveOnlyBranches) {
4267
+ if (stillPendingCreatedBranches.has(pendingCreatedBranchKey(project.projectId, branchName))) {
4268
+ continue;
4269
+ }
4270
+ deactivatePreserveOnlyProjectBranch(project, branchName);
4271
+ }
4272
+ }
4273
+ const preserveOnlyKeys = new Set(preserveOnlyBranches.map(({ projectId, branchName }) => projectBranchKey(projectId, branchName)));
3142
4274
  for (const existingProject of projectConfigById.values()) {
3143
4275
  const nextProject = nextProjectConfigById.get(existingProject.projectId);
3144
4276
  if (!nextProject) {
@@ -3148,6 +4280,7 @@ async function startWorker(options) {
3148
4280
  const nextBranches = new Set(nextProject.branches.map(({ branchName }) => branchName));
3149
4281
  for (const existingBranch of existingProject.branches) {
3150
4282
  if (!nextBranches.has(existingBranch.branchName)) {
4283
+ if (preserveOnlyKeys.has(projectBranchKey(existingProject.projectId, existingBranch.branchName))) continue;
3151
4284
  stageProjectBranchDeletion(existingProject, existingBranch.branchName, false);
3152
4285
  }
3153
4286
  }
@@ -3163,40 +4296,30 @@ async function startWorker(options) {
3163
4296
  for (const projectId of [...readyProjectIds]) {
3164
4297
  if (!projectConfigById.has(projectId)) readyProjectIds.delete(projectId);
3165
4298
  }
3166
- ensureWorkspaceGitClone({
3167
- workspacePath: workspaceShadowRoot,
3168
- remoteUrl: message.workspaceRemoteUrl,
3169
- credentialHelper: nextWorkspaceCredentialHelper,
3170
- credentialUsername: workerCredentialUsername,
3171
- gitIdentity: message.gitIdentity
3172
- });
3173
- pruneStaleOuterWorkspaceEntries(projectWorkspaceStateStore.discoverStaleOuterEntries(workspaceShadowRoot));
3174
4299
  stageDurableWorkspaceDeletions();
3175
4300
  const mountsBeforeGit = buildWorkspaceMounts();
3176
4301
  hydrateWorkspaceGitMounts(
3177
4302
  workspaceShadowRoot,
3178
- mountsBeforeGit.filter((mount) => !mount.deleteWhenSourceMissing && !fs.existsSync(mount.sourcePath))
4303
+ mountsBeforeGit.filter((mount) => !mount.deleteWhenSourceMissing && !fs.existsSync(mount.sourcePath) && mount.busy?.() !== true),
4304
+ // These mounts are intentionally hydrated before their project Git
4305
+ // layouts can be marked ready. No process can resolve an unready
4306
+ // target, and preserving the fetched outer tree here prevents clean
4307
+ // checkout initialization from erasing synchronized dirt.
4308
+ { ignoreBusy: true }
3179
4309
  );
3180
4310
  ensureConfiguredProjects();
3181
- let result = await performWorkspaceSync({
4311
+ const result = await performWorkspaceSync({
3182
4312
  attemptId: crypto.randomUUID(),
3183
4313
  trigger: { type: "connect" }
3184
4314
  });
3185
- if (["no_change", "published", "updated", "conflict_reset", "reset"].includes(result.outcome)) {
3186
- const staleAfterInbound = projectWorkspaceStateStore.discoverStaleOuterEntries(workspaceShadowRoot);
3187
- if (staleAfterInbound.length > 0) {
3188
- pruneStaleOuterWorkspaceEntries(staleAfterInbound);
3189
- stageDurableWorkspaceDeletions();
3190
- result = await performWorkspaceSync({
3191
- attemptId: crypto.randomUUID(),
3192
- trigger: { type: "connect" }
3193
- });
3194
- }
3195
- }
3196
4315
  const publishedHead = result.publishedHead ?? result.localHead ?? result.startingHead;
3197
4316
  if (publishedHead && ["no_change", "published", "updated", "conflict_reset", "reset"].includes(result.outcome)) {
4317
+ const activeMountIds = new Set(result.activeMountIds ?? []);
3198
4318
  const layoutCleanupIds = projectWorkspaceState.pendingTreeDeletions.flatMap(
3199
- (deletion) => deletion.kind === "project" && !deletion.projectDeleted ? [deletion.tombstoneId] : []
4319
+ (deletion) => deletion.kind === "project" && !deletion.projectDeleted && checkoutPathMovePublicationComplete(
4320
+ deletion.branchNames.map((branchName) => projectMountId(deletion.projectId, branchName)),
4321
+ activeMountIds
4322
+ ) ? [deletion.tombstoneId] : []
3200
4323
  );
3201
4324
  if (layoutCleanupIds.length > 0) {
3202
4325
  projectWorkspaceState = projectWorkspaceStateStore.recordTreePublication({
@@ -3212,18 +4335,37 @@ async function startWorker(options) {
3212
4335
  return { result, pending, aheadOfOriginBranches: collectAheadOfOriginBranches() };
3213
4336
  });
3214
4337
  } catch (error) {
3215
- workspaceConfigured = fs.existsSync(path.join(workspaceShadowRoot, ".git"));
3216
- for (const project of message.projects) {
3217
- for (const branch of project.branches) {
3218
- pendingCheckouts.set(projectBranchKey(project.projectId, branch.branchName), {
3219
- projectId: project.projectId,
3220
- branchName: branch.branchName
3221
- });
4338
+ if (error instanceof RegistryAuthConfigurationError) {
4339
+ workspaceConfigured = false;
4340
+ githubCredential = null;
4341
+ configuredCredentialGenerationFingerprint = null;
4342
+ let childTerminationProven = true;
4343
+ try {
4344
+ await terminateActiveCredentialBearingChildren();
4345
+ } catch (childError) {
4346
+ childTerminationProven = false;
4347
+ process.stderr.write(
4348
+ `[r5d-worker] failed to fully terminate credential-bearing children: ${childError instanceof Error ? childError.message : String(childError)}
4349
+ `
4350
+ );
3222
4351
  }
4352
+ process.stderr.write(
4353
+ `[r5d-worker] fatal credential-generation transition failure; exiting for whole-runtime cleanup: ${error instanceof Error ? error.message : String(error)}
4354
+ `
4355
+ );
4356
+ requireCredentialGenerationRestart(true, void 0, 1);
4357
+ }
4358
+ workspaceConfigured = false;
4359
+ const deferred = error instanceof ProjectWorkspaceConfigurationDeferredError || error instanceof ProjectWorkspacePendingBranchPathChangeError;
4360
+ if (deferred) pendingCheckouts.clear();
4361
+ for (const pending of deferredProjectConfigurationPendingBranches(message.projects)) {
4362
+ pendingCheckouts.set(projectBranchKey(pending.projectId, pending.branchName), pending);
3223
4363
  }
3224
4364
  return {
3225
4365
  result: failedWorkspaceSyncResult(crypto.randomUUID(), { type: "connect" }, error),
3226
- pending: [...pendingCheckouts.values()],
4366
+ pending: [...pendingCheckouts.values()].sort(
4367
+ (left, right) => left.projectId.localeCompare(right.projectId) || left.branchName.localeCompare(right.branchName)
4368
+ ),
3227
4369
  aheadOfOriginBranches: []
3228
4370
  };
3229
4371
  } finally {
@@ -3318,12 +4460,22 @@ async function startWorker(options) {
3318
4460
  ws.addEventListener("message", (event) => {
3319
4461
  void (async () => {
3320
4462
  const message = JSON.parse(String(event.data));
4463
+ const messageAdmissionGeneration = workerAdmissionGeneration;
4464
+ const assertMessageAdmission = () => assertWorkerChildAdmission({
4465
+ capturedGeneration: messageAdmissionGeneration,
4466
+ currentGeneration: workerAdmissionGeneration,
4467
+ sourceSocketIsCurrent: currentWorkerSocket === ws,
4468
+ sourceSocketOpen: ws.readyState === WebSocket.OPEN,
4469
+ workspaceConfigured,
4470
+ runtimeHealthy: !shutdownAfterClose && !heartbeatTimedOut
4471
+ });
3321
4472
  if (message.type === "connected") {
3322
4473
  return;
3323
4474
  }
3324
4475
  if (message.type === "workspace_config") {
4476
+ advanceWorkerAdmissionGeneration();
3325
4477
  const configured = await configureWorkerWorkspace(message);
3326
- sendWorkerMessage(ws, {
4478
+ sendWorkerMessageFromCurrentSource(ws, {
3327
4479
  type: "workspace_configured",
3328
4480
  requestId: message.requestId,
3329
4481
  result: configured.result,
@@ -3358,59 +4510,85 @@ async function startWorker(options) {
3358
4510
  }
3359
4511
  if (message.type === "create_project_branch") {
3360
4512
  try {
3361
- projectWorkspaceState = projectWorkspaceStateStore.recordPendingCreatedBranch({
4513
+ const pendingBranch = {
4514
+ branchId: message.branchId,
3362
4515
  projectId: message.projectId,
3363
4516
  branchName: message.targetBranch
3364
- });
4517
+ };
3365
4518
  const created = await workspaceSyncSingleFlight.runMutation(() => {
3366
4519
  const project = projectConfigById.get(message.projectId);
3367
4520
  if (!project) throw new Error(`Project ${message.projectId} is missing from the worker workspace configuration`);
3368
- if (!readyProjectIds.has(project.projectId)) {
3369
- throw new Error(`Project ${project.projectPath} is not initialized on this worker`);
4521
+ assertRepositoryExecutionEnabled(project);
4522
+ if (!repositoryMirrorWritesAllowed(project)) {
4523
+ throw new Error(`Project ${message.projectId} branch creation is disabled while its repository connection is transitioning`);
3370
4524
  }
3371
- if (!project.branches.some(({ branchName }) => branchName === message.sourceBranch)) {
3372
- throw new Error(`Source branch ${message.sourceBranch} is missing from the worker workspace configuration`);
3373
- }
3374
- if (project.branches.some(({ branchName }) => branchName === message.targetBranch)) {
3375
- throw new Error(`Project branch ${message.targetBranch} already exists`);
3376
- }
3377
- const createdBranch = createLinkedProjectBranch({
3378
- projectRoot: configuredProjectRoot(projectsRoot, project),
3379
- sourceBranchName: message.sourceBranch,
3380
- branchName: message.targetBranch
3381
- });
3382
- projectConfigById.set(project.projectId, {
3383
- ...project,
3384
- branches: [
3385
- ...project.branches,
3386
- {
3387
- branchName: message.targetBranch,
3388
- sourceBranchName: message.sourceBranch,
3389
- baseCommitHash: createdBranch.baseCommitHash
4525
+ const pendingWasRecorded = projectWorkspaceState.locallyPendingCreatedBranches.some(
4526
+ ({ projectId, branchName }) => projectId === message.projectId && branchName === message.targetBranch
4527
+ );
4528
+ projectWorkspaceState = projectWorkspaceStateStore.recordPendingCreatedBranch(pendingBranch);
4529
+ let gitBranchCreated = false;
4530
+ try {
4531
+ if (!readyProjectIds.has(project.projectId)) {
4532
+ throw new Error(`Project ${project.projectPath} is not initialized on this worker`);
4533
+ }
4534
+ if (!project.branches.some(({ branchName }) => branchName === message.sourceBranch)) {
4535
+ throw new Error(`Source branch ${message.sourceBranch} is missing from the worker workspace configuration`);
4536
+ }
4537
+ const existingBranch = project.branches.find(({ branchName }) => branchName === message.targetBranch);
4538
+ if (existingBranch) {
4539
+ if (!pendingWasRecorded) throw new Error(`Project branch ${message.targetBranch} already exists`);
4540
+ if (existingBranch.branchId !== message.branchId) {
4541
+ throw new Error(`Pending project branch ${message.targetBranch} belongs to another durable branch incarnation`);
3390
4542
  }
3391
- ]
3392
- });
3393
- lastObservedProjectHeads.set(projectBranchKey(project.projectId, message.targetBranch), null);
3394
- return createdBranch;
4543
+ const existingPath = configuredProjectBranchPath(projectsRoot, project, message.targetBranch);
4544
+ if (!hasProjectWorktree(existingPath)) {
4545
+ throw new Error(`Pending project branch ${message.targetBranch} is not initialized on this worker`);
4546
+ }
4547
+ return { branchPath: existingPath, baseCommitHash: existingBranch.baseCommitHash };
4548
+ }
4549
+ const createdBranch = createOrRetryLinkedProjectBranch({
4550
+ projectRoot: configuredProjectRoot(projectsRoot, project),
4551
+ primaryBranchName: primaryProjectBranch(project),
4552
+ sourceBranchName: message.sourceBranch,
4553
+ branchName: message.targetBranch,
4554
+ pendingRetry: pendingWasRecorded
4555
+ });
4556
+ gitBranchCreated = true;
4557
+ projectConfigById.set(project.projectId, {
4558
+ ...project,
4559
+ branches: [
4560
+ ...project.branches,
4561
+ {
4562
+ branchId: message.branchId,
4563
+ branchName: message.targetBranch,
4564
+ sourceBranchName: message.sourceBranch,
4565
+ baseCommitHash: createdBranch.baseCommitHash
4566
+ }
4567
+ ]
4568
+ });
4569
+ lastObservedProjectHeads.set(projectBranchKey(project.projectId, message.targetBranch), null);
4570
+ return createdBranch;
4571
+ } catch (error) {
4572
+ if (!pendingWasRecorded && !gitBranchCreated && !projectBranchMayExistAfterCreateFailure(error)) {
4573
+ projectWorkspaceState = projectWorkspaceStateStore.rollbackPendingCreatedBranch(pendingBranch);
4574
+ }
4575
+ throw error;
4576
+ }
3395
4577
  });
4578
+ pendingCreatedBranchPublicationNotBefore.set(
4579
+ pendingCreatedBranchKey(message.projectId, message.targetBranch),
4580
+ Date.now() + PENDING_CREATED_BRANCH_AUTOMATIC_SYNC_GRACE_MS
4581
+ );
3396
4582
  sendWorkerMessage(ws, {
3397
4583
  type: "operation_result",
3398
4584
  requestId: message.requestId,
3399
4585
  result: {
3400
4586
  type: "create_project_branch",
4587
+ branchId: message.branchId,
3401
4588
  branchName: message.targetBranch,
3402
4589
  baseCommitHash: created.baseCommitHash
3403
4590
  }
3404
4591
  });
3405
- markWorkspaceDirty(
3406
- {
3407
- type: "manual",
3408
- projectId: message.projectId,
3409
- branchName: message.targetBranch,
3410
- detail: `created project branch from ${message.sourceBranch}`
3411
- },
3412
- true
3413
- );
3414
4592
  } catch (error) {
3415
4593
  sendWorkerMessage(ws, {
3416
4594
  type: "operation_result",
@@ -3423,42 +4601,66 @@ async function startWorker(options) {
3423
4601
  if (message.type === "delete_project_branch") {
3424
4602
  try {
3425
4603
  const deletionKey = projectBranchKey(message.projectId, message.branchName);
3426
- projectWorkspaceState = projectWorkspaceStateStore.beginBranchDeletion({
3427
- projectId: message.projectId,
3428
- branchName: message.branchName
3429
- });
3430
- await workspaceSyncSingleFlight.runMutation(() => {
4604
+ const deletionNeedsSync = await workspaceSyncSingleFlight.runMutation(() => {
3431
4605
  const project = projectConfigById.get(message.projectId);
3432
4606
  if (!project) throw new Error(`Project ${message.projectId} is missing from the worker workspace configuration`);
3433
- if (pendingMirrorDeletes.has(deletionKey)) return;
4607
+ assertRepositoryExecutionEnabled(project);
4608
+ if (!repositoryMirrorWritesAllowed(project)) {
4609
+ throw new Error(`Project ${message.projectId} branch deletion is disabled while its repository connection is transitioning`);
4610
+ }
4611
+ const configuredBranch = project.branches.find(({ branchName }) => branchName === message.branchName);
4612
+ const pendingLocalBranch = projectWorkspaceState.locallyPendingCreatedBranches.find(
4613
+ ({ projectId, branchName }) => projectId === message.projectId && branchName === message.branchName
4614
+ );
4615
+ assertProjectBranchDeletionIncarnation({
4616
+ requiredBranchId: message.branchId,
4617
+ configuredBranch,
4618
+ pendingLocalBranch
4619
+ });
4620
+ const hasDurableTombstone = projectWorkspaceState.tombstones.some(
4621
+ (tombstone) => tombstone.kind === "branch" && tombstone.projectId === message.projectId && tombstone.branchName === message.branchName
4622
+ );
4623
+ if (!configuredBranch && !pendingLocalBranch && !hasDurableTombstone && !pendingMirrorDeletes.has(deletionKey)) {
4624
+ return false;
4625
+ }
4626
+ projectWorkspaceState = projectWorkspaceStateStore.beginBranchDeletion({
4627
+ projectId: message.projectId,
4628
+ branchName: message.branchName
4629
+ });
4630
+ pendingCreatedBranchPublicationNotBefore.delete(pendingCreatedBranchKey(message.projectId, message.branchName));
4631
+ if (pendingMirrorDeletes.has(deletionKey)) return true;
3434
4632
  stageProjectBranchDeletion(project, message.branchName, true);
3435
4633
  projectConfigById.set(project.projectId, {
3436
4634
  ...project,
3437
4635
  branches: project.branches.filter(({ branchName }) => branchName !== message.branchName)
3438
4636
  });
4637
+ return true;
3439
4638
  });
3440
- const result = await runWorkspaceSync({
3441
- trigger: {
3442
- type: "manual",
3443
- projectId: message.projectId,
3444
- branchName: message.branchName,
3445
- detail: "deleted project branch"
3446
- },
3447
- confirmedLargeDiff: true,
3448
- confirmationReason: "explicit project branch deletion",
3449
- sendResult: false
3450
- });
3451
- if (result.outcome === "failed" || result.outcome === "conflict_blocked" || result.outcome === "large_diff_blocked") {
3452
- throw new Error(result.error ?? `Project branch deletion workspace sync ended with ${result.outcome}`);
3453
- }
3454
- if (pendingMirrorDeletes.has(deletionKey)) {
3455
- throw new Error("Project branch deletion did not remove the hidden mirror ref");
4639
+ if (deletionNeedsSync) {
4640
+ const result = await runWorkspaceSync({
4641
+ trigger: {
4642
+ type: "manual",
4643
+ projectId: message.projectId,
4644
+ branchName: message.branchName,
4645
+ detail: "deleted project branch"
4646
+ },
4647
+ confirmedLargeDiff: true,
4648
+ confirmationReason: "explicit project branch deletion",
4649
+ sendResult: false
4650
+ });
4651
+ if (result.outcome === "failed" || result.outcome === "conflict_blocked" || result.outcome === "large_diff_blocked") {
4652
+ throw new Error(result.error ?? `Project branch deletion workspace sync ended with ${result.outcome}`);
4653
+ }
4654
+ if (pendingMirrorDeletes.has(deletionKey)) {
4655
+ throw new Error("Project branch deletion did not remove the hidden mirror ref");
4656
+ }
3456
4657
  }
3457
4658
  sendWorkerMessage(ws, {
3458
4659
  type: "operation_result",
3459
4660
  requestId: message.requestId,
3460
4661
  result: {
3461
4662
  type: "delete_project_branch",
4663
+ branchId: message.branchId,
3462
4664
  branchName: message.branchName
3463
4665
  }
3464
4666
  });
@@ -3646,9 +4848,13 @@ async function startWorker(options) {
3646
4848
  });
3647
4849
  return;
3648
4850
  }
3649
- const releaseWorkspaceMutation = await acquireWorkspaceCommandMutation(message.target, workspaceSyncSingleFlight);
4851
+ await reserveWorkspaceCommandAfterCurrentSync(message.target, workspaceSyncSingleFlight, () => {
4852
+ workspaceSyncPriorityPtyTargets.set(message.ptyId, message.target);
4853
+ });
4854
+ let releaseWorkspaceMutation;
3650
4855
  let mutationLeaseTransferred = false;
3651
4856
  try {
4857
+ releaseWorkspaceMutation = await acquireWorkspaceCommandMutation(message.target, workspaceSyncSingleFlight);
3652
4858
  const resolvedTarget = resolveMessageTarget(message.target);
3653
4859
  process.stdout.write(`[r5d-worker] pty ${message.ptyId}: ${describeWorkerSessionTarget(message.target)}
3654
4860
  `);
@@ -3657,6 +4863,7 @@ async function startWorker(options) {
3657
4863
  message,
3658
4864
  resolvedTarget,
3659
4865
  planRoot,
4866
+ assertAdmission: assertMessageAdmission,
3660
4867
  ...releaseWorkspaceMutation ? { releaseWorkspaceMutation } : {},
3661
4868
  ...targetMayMutateVisibleWorkspace(message.target) ? {
3662
4869
  onTerminal: () => {
@@ -3666,13 +4873,14 @@ async function startWorker(options) {
3666
4873
  });
3667
4874
  mutationLeaseTransferred = releaseWorkspaceMutation !== void 0;
3668
4875
  } catch (error) {
3669
- sendWorkerMessage(ws, {
4876
+ sendWorkerMessageFromCurrentSource(ws, {
3670
4877
  type: "pty_error",
3671
4878
  requestId: message.requestId,
3672
4879
  ptyId: message.ptyId,
3673
4880
  error: error instanceof Error ? error.message : String(error)
3674
4881
  });
3675
4882
  } finally {
4883
+ workspaceSyncPriorityPtyTargets.delete(message.ptyId);
3676
4884
  if (!mutationLeaseTransferred) releaseWorkspaceMutation?.();
3677
4885
  }
3678
4886
  return;
@@ -3709,15 +4917,29 @@ async function startWorker(options) {
3709
4917
  return;
3710
4918
  }
3711
4919
  let result;
4920
+ let targetReserved = false;
3712
4921
  try {
4922
+ await reserveWorkspaceCommandAfterCurrentSync(message.target, workspaceSyncSingleFlight, () => {
4923
+ workspaceSyncPriorityProcessTargets.set(message.runId, message.target);
4924
+ targetReserved = true;
4925
+ });
3713
4926
  const runCommand = async () => {
3714
4927
  const resolvedTarget = resolveMessageTarget(message.target);
3715
4928
  process.stdout.write(`[r5d-worker] exec ${message.runId}: ${message.argv.join(" ")}
3716
4929
  `);
3717
- return await executeCommand({ message, resolvedTarget, baseUrl, token, artifactRoot, planRoot });
4930
+ return await executeCommand({
4931
+ message,
4932
+ resolvedTarget,
4933
+ baseUrl,
4934
+ token,
4935
+ artifactRoot,
4936
+ planRoot,
4937
+ assertAdmission: assertMessageAdmission
4938
+ });
3718
4939
  };
3719
4940
  result = await runWorkspaceCommand(message.target, workspaceSyncSingleFlight, runCommand);
3720
4941
  } catch (error) {
4942
+ if (error instanceof StaleWorkerAdmissionError) return;
3721
4943
  result = {
3722
4944
  type: "exec_result",
3723
4945
  requestId: message.requestId,
@@ -3726,6 +4948,8 @@ async function startWorker(options) {
3726
4948
  exitCode: 1,
3727
4949
  error: error instanceof Error ? error.message : String(error)
3728
4950
  };
4951
+ } finally {
4952
+ if (targetReserved) workspaceSyncPriorityProcessTargets.delete(message.runId);
3729
4953
  }
3730
4954
  ws.send(JSON.stringify(result));
3731
4955
  if (targetMayMutateVisibleWorkspace(message.target)) {
@@ -3763,7 +4987,8 @@ async function startWorker(options) {
3763
4987
  baseUrl,
3764
4988
  token,
3765
4989
  artifactRoot,
3766
- planRoot
4990
+ planRoot,
4991
+ assertAdmission: assertMessageAdmission
3767
4992
  });
3768
4993
  } finally {
3769
4994
  if (targetMayMutateVisibleWorkspace(message.target)) {
@@ -3779,9 +5004,21 @@ async function startWorker(options) {
3779
5004
  }
3780
5005
  }
3781
5006
  };
3782
- const execution = runWorkspaceCommand(message.target, workspaceSyncSingleFlight, runCommand);
5007
+ const execution = (async () => {
5008
+ let targetReserved = false;
5009
+ try {
5010
+ await reserveWorkspaceCommandAfterCurrentSync(message.target, workspaceSyncSingleFlight, () => {
5011
+ workspaceSyncPriorityProcessTargets.set(message.runId, message.target);
5012
+ targetReserved = true;
5013
+ });
5014
+ await runWorkspaceCommand(message.target, workspaceSyncSingleFlight, runCommand);
5015
+ } finally {
5016
+ if (targetReserved) workspaceSyncPriorityProcessTargets.delete(message.runId);
5017
+ }
5018
+ })();
3783
5019
  void execution.catch((error) => {
3784
- sendWorkerMessage(ws, {
5020
+ if (error instanceof StaleWorkerAdmissionError) return;
5021
+ sendWorkerMessageFromCurrentSource(ws, {
3785
5022
  type: "exec_start_error",
3786
5023
  requestId: message.requestId,
3787
5024
  runId: message.runId,
@@ -3791,6 +5028,14 @@ async function startWorker(options) {
3791
5028
  return;
3792
5029
  }
3793
5030
  if (message.type === "read" || message.type === "write" || message.type === "edit" || message.type === "grep" || message.type === "find" || message.type === "ls" || message.type === "view_file_bytes" || message.type === "code_list" || message.type === "code_read") {
5031
+ const reservesVisibleWorkspace = targetMayMutateVisibleWorkspace(message.target);
5032
+ const mutatesVisibleWorkspace = (message.type === "write" || message.type === "edit") && targetMayMutateVisibleWorkspace(message.target);
5033
+ if (reservesVisibleWorkspace) {
5034
+ await reserveWorkspaceCommandAfterCurrentSync(message.target, workspaceSyncSingleFlight, () => {
5035
+ workspaceSyncPriorityOperationTargets.set(message.requestId, message.target);
5036
+ });
5037
+ }
5038
+ let dirtyTrigger;
3794
5039
  try {
3795
5040
  const result = await runWorkspaceCommand(message.target, workspaceSyncSingleFlight, async () => {
3796
5041
  const resolvedTarget = resolveMessageTarget(message.target);
@@ -3810,13 +5055,14 @@ async function startWorker(options) {
3810
5055
  result
3811
5056
  })
3812
5057
  );
3813
- if ((message.type === "write" || message.type === "edit") && targetMayMutateVisibleWorkspace(message.target)) {
3814
- markWorkspaceDirty({
5058
+ if (mutatesVisibleWorkspace) {
5059
+ dirtyTrigger = {
3815
5060
  type: message.type,
3816
5061
  sessionId: message.sessionId,
3817
5062
  toolCallId: message.requestId,
3818
5063
  ...message.target.type === "project" ? { projectId: message.target.projectId, branchName: message.target.branchName } : {}
3819
- });
5064
+ };
5065
+ markWorkspaceDirty(dirtyTrigger);
3820
5066
  }
3821
5067
  } catch (error) {
3822
5068
  ws.send(
@@ -3826,6 +5072,13 @@ async function startWorker(options) {
3826
5072
  error: error instanceof Error ? error.message : String(error)
3827
5073
  })
3828
5074
  );
5075
+ } finally {
5076
+ if (reservesVisibleWorkspace) {
5077
+ workspaceSyncPriorityOperationTargets.delete(message.requestId);
5078
+ if (pendingAutomaticTrigger && !activeWorkspaceIncidentId) {
5079
+ scheduleAutomaticWorkspaceSync(pendingAutomaticTrigger, dirtyTrigger ? WORKSPACE_GIT_QUIET_MS : 0);
5080
+ }
5081
+ }
3829
5082
  }
3830
5083
  return;
3831
5084
  }
@@ -3863,6 +5116,7 @@ async function startWorker(options) {
3863
5116
  terminalReplayTimer = void 0;
3864
5117
  }
3865
5118
  if (currentWorkerSocket === ws) {
5119
+ advanceWorkerAdmissionGeneration();
3866
5120
  currentWorkerSocket = null;
3867
5121
  }
3868
5122
  closeAllPtys();
@@ -3875,16 +5129,16 @@ async function startWorker(options) {
3875
5129
  if (shutdownAfterClose) {
3876
5130
  process.exit(0);
3877
5131
  }
3878
- if (activeProcesses.size > 0 || pendingProcessTerminals.size > 0) {
5132
+ if (activeProcesses.size > 0 || credentialBearingProcessGroups.size > 0 || activePtys.size > 0 || pendingProcessTerminals.size > 0) {
3879
5133
  process.stderr.write(
3880
- `[r5d-worker] ${activeProcesses.size} process(es) active and ${pendingProcessTerminals.size} terminal report(s) pending; reconnecting in ${WORKER_RECONNECT_DELAY_MS}ms
5134
+ `[r5d-worker] ${activeProcesses.size} process(es), ${credentialBearingProcessGroups.size} owned process group(s), and ${activePtys.size} PTY(s) active with ${pendingProcessTerminals.size} terminal report(s) pending; reconnecting in ${WORKER_RECONNECT_DELAY_MS}ms
3881
5135
  `
3882
5136
  );
3883
5137
  const reconnect = () => {
3884
5138
  void startWorker(options).catch((error) => {
3885
5139
  process.stderr.write(`[r5d-worker] reconnect failed: ${error instanceof Error ? error.message : String(error)}
3886
5140
  `);
3887
- if (activeProcesses.size > 0 || pendingProcessTerminals.size > 0) {
5141
+ if (activeProcesses.size > 0 || credentialBearingProcessGroups.size > 0 || activePtys.size > 0 || pendingProcessTerminals.size > 0) {
3888
5142
  setTimeout(reconnect, WORKER_RECONNECT_DELAY_MS);
3889
5143
  return;
3890
5144
  }
@@ -3966,6 +5220,7 @@ export {
3966
5220
  resolveWorkerFilePath,
3967
5221
  resolveWorkerSessionTarget,
3968
5222
  syncSessionArtifacts,
5223
+ workerChildProcessEnvironment,
3969
5224
  workerGitSecurityTestHarness,
3970
5225
  writeWorkerTextFile
3971
5226
  };