@ricsam/r5d-worker 0.0.120 → 0.0.122

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cjs/main.cjs CHANGED
@@ -74,6 +74,7 @@ var import_bun_sqlite = require("bun:sqlite");
74
74
  var import_cli_update = require("./cli-update.cjs");
75
75
  var import_git_process_environment = require("./git-process-environment.cjs");
76
76
  var import_process_tree = require("./process-tree.cjs");
77
+ var import_project_checkout_garbage = require("./project-checkout-garbage.cjs");
77
78
  var import_pty_output_coalescer = require("./pty-output-coalescer.cjs");
78
79
  var import_port_forward_client = require("./port-forward-client.cjs");
79
80
  var import_registry_auth = require("./registry-auth.cjs");
@@ -274,6 +275,15 @@ let workerAdmissionGeneration = 0;
274
275
  const workspaceMutationGate = new import_workspace_mutation_gate.WorkspaceMutationGate();
275
276
  let workspaceSyncQueue = Promise.resolve();
276
277
  let startupProjectSnapshotRecoveryCompleted = false;
278
+ const checkoutGarbageCollectors = /* @__PURE__ */ new Map();
279
+ function checkoutGarbageCollectorFor(projectsRoot) {
280
+ let collector = checkoutGarbageCollectors.get(projectsRoot);
281
+ if (!collector) {
282
+ collector = new import_project_checkout_garbage.ProjectCheckoutGarbageCollector({ projectsRoot });
283
+ checkoutGarbageCollectors.set(projectsRoot, collector);
284
+ }
285
+ return collector;
286
+ }
277
287
  const workspaceSyncSingleFlight = {
278
288
  runExclusive(operation) {
279
289
  const queued = workspaceMutationGate.runSync(operation);
@@ -940,24 +950,43 @@ function tryGit(args, options = {}) {
940
950
  return false;
941
951
  }
942
952
  }
953
+ class GitTransportTimeoutError extends Error {
954
+ }
943
955
  async function runGitAsync(args, options = {}) {
944
956
  const command = workerGitCommand.commandArgs(args);
957
+ const bounded = options.timeoutMs !== void 0;
945
958
  const subprocess = Bun.spawn(command, {
946
959
  cwd: options.cwd,
947
960
  stdin: "ignore",
948
961
  stdout: "pipe",
949
962
  stderr: "pipe",
950
- env: (0, import_git_process_environment.workerGitProcessEnvironment)()
963
+ env: (0, import_git_process_environment.workerGitProcessEnvironment)(),
964
+ ...bounded ? { detached: true } : {}
951
965
  });
952
- const [stdout, stderr, exitCode] = await Promise.all([
966
+ const completion = Promise.all([
953
967
  new Response(subprocess.stdout).text(),
954
968
  new Response(subprocess.stderr).text(),
955
969
  subprocess.exited
956
- ]);
957
- if (exitCode !== 0) {
958
- throw new Error(`git ${args.join(" ")} failed: ${stderr.trim() || stdout.trim() || `exit ${exitCode}`}`);
970
+ ]).then(([stdout, stderr, exitCode]) => ({ stdout, stderr, exitCode }));
971
+ let timer;
972
+ const timedOut = bounded ? new Promise((_resolve, reject) => {
973
+ timer = setTimeout(() => reject(new GitTransportTimeoutError(`git ${args.join(" ")} timed out after ${options.timeoutMs}ms`)), options.timeoutMs);
974
+ }) : null;
975
+ try {
976
+ const { stdout, stderr, exitCode } = timedOut ? await Promise.race([completion, timedOut]) : await completion;
977
+ if (exitCode !== 0) {
978
+ throw new Error(`git ${args.join(" ")} failed: ${stderr.trim() || stdout.trim() || `exit ${exitCode}`}`);
979
+ }
980
+ return stdout.trim();
981
+ } catch (error) {
982
+ if (error instanceof GitTransportTimeoutError) {
983
+ void (0, import_process_tree.terminateProcessTree)(subprocess, { graceMs: 0 }).catch(() => void 0);
984
+ void completion.catch(() => void 0);
985
+ }
986
+ throw error;
987
+ } finally {
988
+ if (timer) clearTimeout(timer);
959
989
  }
960
- return stdout.trim();
961
990
  }
962
991
  async function tryGitAsync(args, options = {}) {
963
992
  try {
@@ -1818,14 +1847,13 @@ const workerGitSecurityTestHarness = {
1818
1847
  executeEditFileOperation,
1819
1848
  terminateCredentialBearingChildren,
1820
1849
  terminateCredentialBearingChildrenWithRetention,
1821
- async commitCredentialGeneration(prepared, credential, children, beforeMutation, previousGenerationFenced = false) {
1822
- return await commitCredentialGeneration(
1823
- prepared,
1824
- credential,
1825
- async () => await terminateCredentialBearingChildren(children),
1826
- beforeMutation,
1827
- previousGenerationFenced
1828
- );
1850
+ async commitCredentialGeneration(prepared, credential, options) {
1851
+ return await commitCredentialGeneration(prepared, credential, {
1852
+ secretMaterialChanged: options.secretMaterialChanged,
1853
+ terminatePreviousGeneration: async () => await terminateCredentialBearingChildren(options.children),
1854
+ beforeMutation: options.beforeMutation,
1855
+ previousGenerationFenced: options.previousGenerationFenced ?? false
1856
+ });
1829
1857
  },
1830
1858
  credentialGenerationReceiptPath,
1831
1859
  credentialAuthorityLockPath,
@@ -1863,14 +1891,14 @@ function installGitHubCredentialGeneration(credential) {
1863
1891
  `);
1864
1892
  }
1865
1893
  }
1866
- async function commitCredentialGeneration(prepared, credential, terminatePreviousGeneration, beforeMutation, previousGenerationFenced = false) {
1894
+ async function commitCredentialGeneration(prepared, credential, options) {
1867
1895
  const changed = prepared.changed;
1868
1896
  try {
1869
- if (changed && !previousGenerationFenced) {
1897
+ if (changed && options.secretMaterialChanged && !options.previousGenerationFenced) {
1870
1898
  githubCredential = null;
1871
- await terminatePreviousGeneration();
1899
+ await options.terminatePreviousGeneration();
1872
1900
  }
1873
- prepared.commit(beforeMutation);
1901
+ prepared.commit(options.beforeMutation);
1874
1902
  } catch (error) {
1875
1903
  prepared.discard();
1876
1904
  throw error instanceof import_registry_auth.RegistryAuthConfigurationError ? error : new import_registry_auth.RegistryAuthConfigurationError(error);
@@ -3686,6 +3714,12 @@ async function startWorker(options, projectRuntime = {
3686
3714
  projectRuntime.initializedWorkspaceState = initializedWorkspaceState;
3687
3715
  const projectWorkspaceStateStore = initializedWorkspaceState.store;
3688
3716
  let projectWorkspaceState = initializedWorkspaceState.store.read();
3717
+ const checkoutGarbageCollector = checkoutGarbageCollectorFor(projectsRoot);
3718
+ const staleStagedCheckouts = checkoutGarbageCollector.sweep();
3719
+ if (staleStagedCheckouts > 0) {
3720
+ process.stderr.write(`[r5d-worker] collecting ${staleStagedCheckouts} deleted checkout(s) left from an earlier run
3721
+ `);
3722
+ }
3689
3723
  const { projectConfigById, readyProjectIds, reconciledProjectConfigFingerprints } = projectRuntime;
3690
3724
  const pendingCheckouts = /* @__PURE__ */ new Map();
3691
3725
  const lastObservedProjectHeads = /* @__PURE__ */ new Map();
@@ -3800,6 +3834,7 @@ async function startWorker(options, projectRuntime = {
3800
3834
  `
3801
3835
  );
3802
3836
  }
3837
+ if (deleted.stagedForCollectionPath) checkoutGarbageCollector.enqueue(deleted.stagedForCollectionPath);
3803
3838
  }
3804
3839
  const planSourcePath = import_node_path.default.join(planRoot, project.projectId, ...branchName.split("/"));
3805
3840
  import_node_fs.default.rmSync(planSourcePath, { recursive: true, force: true });
@@ -3883,6 +3918,7 @@ async function startWorker(options, projectRuntime = {
3883
3918
  `
3884
3919
  );
3885
3920
  }
3921
+ if (deleted.stagedForCollectionPath) checkoutGarbageCollector.enqueue(deleted.stagedForCollectionPath);
3886
3922
  import_node_fs.default.rmSync(import_node_path.default.join(planRoot, project.projectId, ...branchName.split("/")), { recursive: true, force: true });
3887
3923
  readyProjectIds.delete(project.projectId);
3888
3924
  reconciledProjectConfigFingerprints.delete(project.projectId);
@@ -4319,33 +4355,56 @@ async function startWorker(options, projectRuntime = {
4319
4355
  return reconciledProjectIds;
4320
4356
  };
4321
4357
  const ORIGIN_DIVERGENCE_REFRESH_INTERVAL_MS = 5 * 60 * 1e3;
4358
+ const ORIGIN_DIVERGENCE_FETCH_TIMEOUT_MS = 3e4;
4359
+ const ORIGIN_DIVERGENCE_REFRESH_BUDGET_MS = 9e4;
4322
4360
  const lastOriginDivergenceFetchMs = /* @__PURE__ */ new Map();
4323
- const collectAheadOfOriginBranches = async (refetchOriginProjectIds) => {
4361
+ const collectAheadOfOriginBranches = async (refetchOriginProjectIds, onProgress) => {
4324
4362
  const aheadBranches = [];
4325
4363
  for (const projectId of [...lastOriginDivergenceFetchMs.keys()]) {
4326
4364
  if (!projectConfigById.has(projectId)) lastOriginDivergenceFetchMs.delete(projectId);
4327
4365
  }
4328
- for (const project of projectConfigById.values()) {
4366
+ const passStartedAtMs = Date.now();
4367
+ let budgetSpentReported = false;
4368
+ const projects = [...projectConfigById.values()];
4369
+ for (const [projectIndex, project] of projects.entries()) {
4329
4370
  if (project.executionDisabled || !readyProjectIds.has(project.projectId) || project.branches.length === 0) continue;
4371
+ onProgress?.({
4372
+ detail: `Checking origin divergence for ${project.projectPath}`,
4373
+ completedItems: projectIndex,
4374
+ totalItems: projects.length
4375
+ });
4330
4376
  const connection = projectConnection(project);
4331
4377
  const primaryPath = configuredProjectBranchPath(projectsRoot, project, primaryProjectBranch(project));
4332
4378
  const lastFetchedAtMs = lastOriginDivergenceFetchMs.get(project.projectId);
4333
4379
  if (refetchOriginProjectIds.has(project.projectId) || lastFetchedAtMs === void 0 || Date.now() - lastFetchedAtMs >= ORIGIN_DIVERGENCE_REFRESH_INTERVAL_MS) {
4334
- if (await tryGitAsync(
4335
- [
4336
- ...(0, import_git_process_environment.gitTransportSecurityArgs)(connection.originUrl, connection.credentialHelper, connection.originCredentialUsername),
4337
- "fetch",
4338
- "--no-recurse-submodules",
4339
- "--prune",
4340
- "origin",
4341
- "+refs/heads/*:refs/remotes/origin/*"
4342
- ],
4343
- { cwd: primaryPath }
4344
- )) {
4345
- lastOriginDivergenceFetchMs.set(project.projectId, Date.now());
4380
+ if (Date.now() - passStartedAtMs >= ORIGIN_DIVERGENCE_REFRESH_BUDGET_MS) {
4381
+ if (!budgetSpentReported) {
4382
+ budgetSpentReported = true;
4383
+ process.stderr.write(
4384
+ `[r5d-worker] origin divergence refresh budget of ${ORIGIN_DIVERGENCE_REFRESH_BUDGET_MS}ms is spent; remaining origins keep their previous comparison until the next refresh
4385
+ `
4386
+ );
4387
+ }
4346
4388
  } else {
4347
- process.stderr.write(`[r5d-worker] could not refresh origin divergence for ${project.projectPath}
4348
- `);
4389
+ try {
4390
+ await runGitAsync(
4391
+ [
4392
+ ...(0, import_git_process_environment.gitTransportSecurityArgs)(connection.originUrl, connection.credentialHelper, connection.originCredentialUsername),
4393
+ "fetch",
4394
+ "--no-recurse-submodules",
4395
+ "--prune",
4396
+ "origin",
4397
+ "+refs/heads/*:refs/remotes/origin/*"
4398
+ ],
4399
+ { cwd: primaryPath, timeoutMs: ORIGIN_DIVERGENCE_FETCH_TIMEOUT_MS }
4400
+ );
4401
+ lastOriginDivergenceFetchMs.set(project.projectId, Date.now());
4402
+ } catch (error) {
4403
+ process.stderr.write(
4404
+ `[r5d-worker] could not refresh origin divergence for ${project.projectPath}: ${error instanceof Error ? error.message : String(error)}
4405
+ `
4406
+ );
4407
+ }
4349
4408
  }
4350
4409
  }
4351
4410
  for (const branch of project.branches) {
@@ -5378,13 +5437,11 @@ async function startWorker(options, projectRuntime = {
5378
5437
  } catch (error) {
5379
5438
  throw error instanceof import_registry_auth.RegistryAuthConfigurationError ? error : new import_registry_auth.RegistryAuthConfigurationError(error);
5380
5439
  }
5381
- await commitCredentialGeneration(
5382
- preparedAuthGeneration,
5383
- message.githubCredential,
5384
- terminateActiveCredentialBearingChildren,
5385
- void 0,
5386
- credentialPublicationPreauthorized
5387
- );
5440
+ await commitCredentialGeneration(preparedAuthGeneration, message.githubCredential, {
5441
+ secretMaterialChanged: credentialTransitionPhase !== "current",
5442
+ terminatePreviousGeneration: terminateActiveCredentialBearingChildren,
5443
+ previousGenerationFenced: credentialPublicationPreauthorized
5444
+ });
5388
5445
  if (receiptGeneration !== workspaceConfigurationReceiptGeneration) {
5389
5446
  throw new Error("Workspace configuration was superseded by a newer server generation");
5390
5447
  }
@@ -5519,7 +5576,15 @@ async function startWorker(options, projectRuntime = {
5519
5576
  }
5520
5577
  };
5521
5578
  const hasDurableBranchDeletion = projectWorkspaceState.tombstones.some((tombstone) => tombstone.kind === "branch");
5522
- const result = await performWorkspaceSync({
5579
+ sendLifecycleProgress({
5580
+ phase: "configuring",
5581
+ operationId: configurationOperationId,
5582
+ detail: `Synchronizing the workspace after configuring ${message.projects.length} project(s)`,
5583
+ completedItems: message.projects.length,
5584
+ totalItems: message.projects.length
5585
+ });
5586
+ const connectSyncStartedAtMs = Date.now();
5587
+ const syncResult = await performWorkspaceSync({
5523
5588
  attemptId: crypto.randomUUID(),
5524
5589
  trigger: { type: "connect" },
5525
5590
  confirmedLargeDiff: hasDurableBranchDeletion,
@@ -5527,6 +5592,11 @@ async function startWorker(options, projectRuntime = {
5527
5592
  resetToCanonical: workspaceConfigurationResetToCanonicalIsAllowed(message.resetToCanonical, incidentDeferral.incidentId),
5528
5593
  assertStillAdmitted: assertConfigurationSyncStillAdmitted
5529
5594
  });
5595
+ const connectSyncMs = Date.now() - connectSyncStartedAtMs;
5596
+ const result = syncResult.telemetry ? syncResult : {
5597
+ ...syncResult,
5598
+ telemetry: { totalMs: connectSyncMs, queueMs: 0, prepareMs: lastWorkspaceSyncMirrorObservationMs, synchronizeMs: connectSyncMs }
5599
+ };
5530
5600
  assertConfigurationSyncStillAdmitted();
5531
5601
  if (receiptGeneration !== workspaceConfigurationReceiptGeneration) {
5532
5602
  throw new Error("Workspace configuration was superseded by a newer server generation");
@@ -5559,7 +5629,14 @@ async function startWorker(options, projectRuntime = {
5559
5629
  const pending = [...pendingCheckouts.values()].sort(
5560
5630
  (left, right) => left.projectId.localeCompare(right.projectId) || left.branchName.localeCompare(right.branchName)
5561
5631
  );
5562
- return { result, pending, aheadOfOriginBranches: await collectAheadOfOriginBranches(reconciledProjectIds) };
5632
+ return {
5633
+ result,
5634
+ pending,
5635
+ aheadOfOriginBranches: await collectAheadOfOriginBranches(
5636
+ reconciledProjectIds,
5637
+ (progress) => sendLifecycleProgress({ phase: "configuring", operationId: configurationOperationId, ...progress })
5638
+ )
5639
+ };
5563
5640
  });
5564
5641
  } catch (error) {
5565
5642
  if (receiptGeneration !== workspaceConfigurationReceiptGeneration) {
@@ -5776,6 +5853,7 @@ async function startWorker(options, projectRuntime = {
5776
5853
  workspaceIncidentConfigDeferralV1: true,
5777
5854
  workspaceConfigResetToCanonicalV1: true,
5778
5855
  projectBranchDeletionFastAckV1: true,
5856
+ projectBranchDeletionStagedRemovalV1: true,
5779
5857
  projectMirrorLeaseV1: true,
5780
5858
  projectMirrorRefsTokensV1: true,
5781
5859
  projectBranchWorkingTreeModeV1: true
@@ -5877,7 +5955,9 @@ async function startWorker(options, projectRuntime = {
5877
5955
  if (admission !== "new") {
5878
5956
  const response = recoveryStore.response(operationRequestId);
5879
5957
  if (response && admission !== "unknown") sendReplayWorkerMessage(ws, response);
5880
- return;
5958
+ if (!(admission === "unknown" && message.type === "delete_project_branch" && recoveryStore.readmitUnknownBranchDeletion(operationRequestId))) {
5959
+ return;
5960
+ }
5881
5961
  }
5882
5962
  }
5883
5963
  const messageAdmissionGeneration = workerAdmissionGeneration;
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.120",
3
+ "version": "0.0.122",
4
4
  "type": "commonjs"
5
5
  }
@@ -0,0 +1,190 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+ var project_checkout_garbage_exports = {};
30
+ __export(project_checkout_garbage_exports, {
31
+ PROJECT_DELETED_CHECKOUTS_DIRECTORY: () => PROJECT_DELETED_CHECKOUTS_DIRECTORY,
32
+ ProjectCheckoutGarbageCollector: () => ProjectCheckoutGarbageCollector,
33
+ discoverStagedProjectCheckouts: () => discoverStagedProjectCheckouts,
34
+ isStagedProjectCheckoutPath: () => isStagedProjectCheckoutPath,
35
+ ownedStagedPath: () => ownedStagedPath,
36
+ removeStagedProjectCheckout: () => removeStagedProjectCheckout,
37
+ stageProjectCheckoutForDeletion: () => stageProjectCheckoutForDeletion
38
+ });
39
+ module.exports = __toCommonJS(project_checkout_garbage_exports);
40
+ var import_node_crypto = require("node:crypto");
41
+ var import_node_fs = __toESM(require("node:fs"), 1);
42
+ var import_node_path = __toESM(require("node:path"), 1);
43
+ const PROJECT_DELETED_CHECKOUTS_DIRECTORY = ".r5d-deleted";
44
+ function isRealDirectory(candidate) {
45
+ try {
46
+ return import_node_fs.default.lstatSync(candidate).isDirectory();
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+ function stageProjectCheckoutForDeletion(projectRoot, branchName, checkoutPath) {
52
+ const garbageRoot = import_node_path.default.join(projectRoot, PROJECT_DELETED_CHECKOUTS_DIRECTORY);
53
+ if (!isRealDirectory(checkoutPath)) throw new Error(`Cannot stage ${checkoutPath} for deletion: not a directory`);
54
+ import_node_fs.default.mkdirSync(garbageRoot, { recursive: true });
55
+ if (!isRealDirectory(garbageRoot)) throw new Error(`Cannot stage ${checkoutPath} for deletion: ${garbageRoot} is not a directory`);
56
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
57
+ const target = import_node_path.default.join(garbageRoot, `${branchName.replace(/\//g, "__")}-${timestamp}-${(0, import_node_crypto.randomUUID)().slice(0, 8)}`);
58
+ import_node_fs.default.renameSync(checkoutPath, target);
59
+ return target;
60
+ }
61
+ function isStagedProjectCheckoutPath(projectsRoot, candidate) {
62
+ const relative = import_node_path.default.relative(import_node_path.default.resolve(projectsRoot), import_node_path.default.resolve(candidate));
63
+ if (!relative || relative.startsWith("..") || import_node_path.default.isAbsolute(relative)) return false;
64
+ const segments = relative.split(import_node_path.default.sep);
65
+ return segments.length === 4 && segments[2] === PROJECT_DELETED_CHECKOUTS_DIRECTORY && segments[3] !== "" && !segments[3].startsWith(".");
66
+ }
67
+ function ownedStagedPath(projectsRoot, candidate) {
68
+ const root = import_node_path.default.resolve(projectsRoot);
69
+ const resolved = import_node_path.default.resolve(candidate);
70
+ if (!isStagedProjectCheckoutPath(root, resolved)) return null;
71
+ let current = root;
72
+ for (const segment of import_node_path.default.relative(root, import_node_path.default.dirname(resolved)).split(import_node_path.default.sep)) {
73
+ current = import_node_path.default.join(current, segment);
74
+ if (!isRealDirectory(current)) return null;
75
+ }
76
+ let stat;
77
+ try {
78
+ stat = import_node_fs.default.lstatSync(resolved);
79
+ } catch {
80
+ return null;
81
+ }
82
+ if (stat.isSymbolicLink()) return { kind: "link" };
83
+ return stat.isDirectory() ? { kind: "directory" } : null;
84
+ }
85
+ function realDirectoryEntries(directory) {
86
+ if (!isRealDirectory(directory)) return [];
87
+ try {
88
+ return import_node_fs.default.readdirSync(directory, { withFileTypes: true }).map((entry) => entry.name).sort();
89
+ } catch {
90
+ return [];
91
+ }
92
+ }
93
+ function discoverStagedProjectCheckouts(projectsRoot) {
94
+ const root = import_node_path.default.resolve(projectsRoot);
95
+ const staged = [];
96
+ for (const namespace of realDirectoryEntries(root)) {
97
+ if (namespace.startsWith(".")) continue;
98
+ for (const project of realDirectoryEntries(import_node_path.default.join(root, namespace))) {
99
+ if (project.startsWith(".")) continue;
100
+ const garbageRoot = import_node_path.default.join(root, namespace, project, PROJECT_DELETED_CHECKOUTS_DIRECTORY);
101
+ for (const entry of realDirectoryEntries(garbageRoot)) {
102
+ const candidate = import_node_path.default.join(garbageRoot, entry);
103
+ if (ownedStagedPath(root, candidate)) staged.push(candidate);
104
+ }
105
+ }
106
+ }
107
+ return staged;
108
+ }
109
+ async function removeStagedProjectCheckout(stagedPath) {
110
+ if (import_node_fs.default.lstatSync(stagedPath).isSymbolicLink()) {
111
+ import_node_fs.default.unlinkSync(stagedPath);
112
+ return;
113
+ }
114
+ if (process.platform === "win32") {
115
+ await import_node_fs.default.promises.rm(stagedPath, { recursive: true, force: true });
116
+ return;
117
+ }
118
+ const command = ["rm", "-rf", "--", stagedPath];
119
+ if (Bun.which("nice")) command.unshift("nice", "-n", "19");
120
+ if (process.platform === "linux" && Bun.which("ionice")) command.unshift("ionice", "-c", "3");
121
+ const child = Bun.spawn(command, { stdin: "ignore", stdout: "ignore", stderr: "pipe" });
122
+ const [exitCode, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()]);
123
+ if (exitCode !== 0) throw new Error(`rm exited ${exitCode}: ${stderr.trim()}`);
124
+ if (import_node_fs.default.existsSync(stagedPath)) throw new Error("staged checkout still exists after removal");
125
+ }
126
+ class ProjectCheckoutGarbageCollector {
127
+ projectsRoot;
128
+ remove;
129
+ log;
130
+ queued = /* @__PURE__ */ new Set();
131
+ tail = Promise.resolve();
132
+ collectedCount = 0;
133
+ constructor(options) {
134
+ this.projectsRoot = import_node_path.default.resolve(options.projectsRoot);
135
+ this.remove = options.remove ?? removeStagedProjectCheckout;
136
+ this.log = options.log ?? ((message) => process.stderr.write(`${message}
137
+ `));
138
+ }
139
+ /** Number of removals that have completed successfully. */
140
+ get collected() {
141
+ return this.collectedCount;
142
+ }
143
+ /** Resolves once everything queued so far has been attempted. */
144
+ get idle() {
145
+ return this.tail;
146
+ }
147
+ enqueue(stagedPath) {
148
+ const resolved = import_node_path.default.resolve(stagedPath);
149
+ if (!ownedStagedPath(this.projectsRoot, resolved)) {
150
+ throw new Error(`Refusing to collect ${stagedPath}: not a staged checkout under ${this.projectsRoot}`);
151
+ }
152
+ if (this.queued.has(resolved)) return;
153
+ this.queued.add(resolved);
154
+ this.tail = this.tail.then(async () => {
155
+ try {
156
+ const owned = ownedStagedPath(this.projectsRoot, resolved);
157
+ if (owned) {
158
+ const startedAt = performance.now();
159
+ await this.remove(resolved);
160
+ this.collectedCount += 1;
161
+ this.log(`[r5d-worker] collected deleted checkout ${resolved} in ${Math.round(performance.now() - startedAt)}ms`);
162
+ } else if (import_node_fs.default.existsSync(resolved) || isRealDirectory(import_node_path.default.dirname(resolved))) {
163
+ this.log(`[r5d-worker] skipped deleted checkout ${resolved}: no longer an owned staged checkout`);
164
+ }
165
+ } catch (error) {
166
+ this.log(
167
+ `[r5d-worker] deleted checkout ${resolved} could not be collected yet: ${error instanceof Error ? error.message : String(error)}`
168
+ );
169
+ } finally {
170
+ this.queued.delete(resolved);
171
+ }
172
+ });
173
+ }
174
+ /** Enqueue every staged checkout left behind by an earlier process. */
175
+ sweep() {
176
+ const staged = discoverStagedProjectCheckouts(this.projectsRoot);
177
+ for (const stagedPath of staged) this.enqueue(stagedPath);
178
+ return staged.length;
179
+ }
180
+ }
181
+ // Annotate the CommonJS export names for ESM import in node:
182
+ 0 && (module.exports = {
183
+ PROJECT_DELETED_CHECKOUTS_DIRECTORY,
184
+ ProjectCheckoutGarbageCollector,
185
+ discoverStagedProjectCheckouts,
186
+ isStagedProjectCheckoutPath,
187
+ ownedStagedPath,
188
+ removeStagedProjectCheckout,
189
+ stageProjectCheckoutForDeletion
190
+ });
@@ -61,6 +61,7 @@ var import_node_os = __toESM(require("node:os"), 1);
61
61
  var import_node_path = __toESM(require("node:path"), 1);
62
62
  var import_git_process_environment = require("./git-process-environment.cjs");
63
63
  var import_managed_paths = require("./managed-paths.cjs");
64
+ var import_project_checkout_garbage = require("./project-checkout-garbage.cjs");
64
65
  var import_project_mirror_refs_token = require("./project-mirror-refs-token.cjs");
65
66
  var import_working_tree_mirror = require("./working-tree-mirror.cjs");
66
67
  const PROJECT_WORKTREE_SNAPSHOT_PREFIX = "r5d-project-worktrees-";
@@ -1340,6 +1341,7 @@ function deleteLinkedProjectBranch(input) {
1340
1341
  const primaryCommonDir = commonGitDirectory(primaryPath);
1341
1342
  if (!primaryCommonDir) throw new Error("Primary project checkout is unavailable");
1342
1343
  let movedAsidePath;
1344
+ let stagedForCollectionPath;
1343
1345
  if (import_node_fs.default.existsSync(checkoutPath)) {
1344
1346
  const checkoutCommonDir = import_node_fs.default.lstatSync(checkoutPath).isDirectory() ? commonGitDirectory(checkoutPath) : null;
1345
1347
  if (checkoutCommonDir !== primaryCommonDir) {
@@ -1349,7 +1351,8 @@ function deleteLinkedProjectBranch(input) {
1349
1351
  if (projectWorktreeOperationInProgress(checkoutPath)) {
1350
1352
  throw new Error(`Project branch ${input.branchName} has an in-progress Git operation`);
1351
1353
  }
1352
- git(primaryPath, ["worktree", "remove", "--force", checkoutPath], `remove linked worktree ${input.branchName}`);
1354
+ stagedForCollectionPath = (0, import_project_checkout_garbage.stageProjectCheckoutForDeletion)(input.projectRoot, input.branchName, checkoutPath);
1355
+ git(primaryPath, ["worktree", "prune"], `prune linked worktree ${input.branchName}`);
1353
1356
  }
1354
1357
  } else {
1355
1358
  tryGit(primaryPath, ["worktree", "prune"]);
@@ -1358,7 +1361,11 @@ function deleteLinkedProjectBranch(input) {
1358
1361
  if (tryGit(primaryPath, ["show-ref", "--verify", "--quiet", branchRef])) {
1359
1362
  git(primaryPath, ["branch", "-D", input.branchName], `delete project branch ${input.branchName}`);
1360
1363
  }
1361
- return { branchName: input.branchName, ...movedAsidePath ? { movedAsidePath } : {} };
1364
+ return {
1365
+ branchName: input.branchName,
1366
+ ...movedAsidePath ? { movedAsidePath } : {},
1367
+ ...stagedForCollectionPath ? { stagedForCollectionPath } : {}
1368
+ };
1362
1369
  }
1363
1370
  function removeProjectWorktrees(input) {
1364
1371
  const projectRoot = import_node_path.default.resolve(input.projectRoot);
@@ -98,6 +98,19 @@ class WorkerRecoveryStore {
98
98
  isUnknown(requestId) {
99
99
  return this.row(requestId)?.state === "unknown";
100
100
  }
101
+ /**
102
+ * Re-admit an operation whose crash outcome is unknown so it can run again
103
+ * and record a result. Only a branch deletion qualifies: it is idempotent
104
+ * for its exact incarnation (durable tombstone plus incarnation preflight),
105
+ * so a rerun converges on the same outcome. Every other unknown operation
106
+ * keeps the no-replay invariant.
107
+ */
108
+ readmitUnknownBranchDeletion(requestId) {
109
+ const row = this.row(requestId);
110
+ if (!row || row.state !== "unknown" || row.request_type !== "delete_project_branch") return false;
111
+ this.db.run("UPDATE operations SET state='accepted', response=NULL WHERE request_id=?", [row.request_id]);
112
+ return true;
113
+ }
101
114
  unknown(requestId) {
102
115
  const row = this.row(requestId);
103
116
  if (row) this.db.run("UPDATE operations SET state='unknown' WHERE request_id=?", [row.request_id]);
@@ -2367,7 +2367,9 @@ async function runWorkspaceGitSynchronization(input, remoteHeadFetches) {
2367
2367
  }
2368
2368
  restoreDeferredWorkspaceMountsFromHead(
2369
2369
  workspacePath,
2370
- input.mounts.filter(({ id }) => deferredMountIds.has(id))
2370
+ input.mounts.filter(
2371
+ (mount) => deferredMountIds.has(mount.id) || mount.preserveLocalOnHydrationBasisChange === true && cycleSkippedMountIds.has(mount.id)
2372
+ )
2371
2373
  );
2372
2374
  await stageAndCommitWorkspace();
2373
2375
  await fsyncWorkspaceCheckoutTreeYielding(workspacePath);
package/dist/mjs/main.mjs CHANGED
@@ -25,6 +25,7 @@ import { Database } from "bun:sqlite";
25
25
  import { installCliUpdate, readInstalledCliVersion } from "./cli-update.mjs";
26
26
  import { gitTransportSecurityArgs, workerGitProcessEnvironment } from "./git-process-environment.mjs";
27
27
  import { terminateProcessTree } from "./process-tree.mjs";
28
+ import { ProjectCheckoutGarbageCollector } from "./project-checkout-garbage.mjs";
28
29
  import { createPtyOutputCoalescer } from "./pty-output-coalescer.mjs";
29
30
  import { openWorkerPortForwardRelay } from "./port-forward-client.mjs";
30
31
  import {
@@ -293,6 +294,15 @@ let workerAdmissionGeneration = 0;
293
294
  const workspaceMutationGate = new WorkspaceMutationGate();
294
295
  let workspaceSyncQueue = Promise.resolve();
295
296
  let startupProjectSnapshotRecoveryCompleted = false;
297
+ const checkoutGarbageCollectors = /* @__PURE__ */ new Map();
298
+ function checkoutGarbageCollectorFor(projectsRoot) {
299
+ let collector = checkoutGarbageCollectors.get(projectsRoot);
300
+ if (!collector) {
301
+ collector = new ProjectCheckoutGarbageCollector({ projectsRoot });
302
+ checkoutGarbageCollectors.set(projectsRoot, collector);
303
+ }
304
+ return collector;
305
+ }
296
306
  const workspaceSyncSingleFlight = {
297
307
  runExclusive(operation) {
298
308
  const queued = workspaceMutationGate.runSync(operation);
@@ -959,24 +969,43 @@ function tryGit(args, options = {}) {
959
969
  return false;
960
970
  }
961
971
  }
972
+ class GitTransportTimeoutError extends Error {
973
+ }
962
974
  async function runGitAsync(args, options = {}) {
963
975
  const command = workerGitCommand.commandArgs(args);
976
+ const bounded = options.timeoutMs !== void 0;
964
977
  const subprocess = Bun.spawn(command, {
965
978
  cwd: options.cwd,
966
979
  stdin: "ignore",
967
980
  stdout: "pipe",
968
981
  stderr: "pipe",
969
- env: workerGitProcessEnvironment()
982
+ env: workerGitProcessEnvironment(),
983
+ ...bounded ? { detached: true } : {}
970
984
  });
971
- const [stdout, stderr, exitCode] = await Promise.all([
985
+ const completion = Promise.all([
972
986
  new Response(subprocess.stdout).text(),
973
987
  new Response(subprocess.stderr).text(),
974
988
  subprocess.exited
975
- ]);
976
- if (exitCode !== 0) {
977
- throw new Error(`git ${args.join(" ")} failed: ${stderr.trim() || stdout.trim() || `exit ${exitCode}`}`);
989
+ ]).then(([stdout, stderr, exitCode]) => ({ stdout, stderr, exitCode }));
990
+ let timer;
991
+ const timedOut = bounded ? new Promise((_resolve, reject) => {
992
+ timer = setTimeout(() => reject(new GitTransportTimeoutError(`git ${args.join(" ")} timed out after ${options.timeoutMs}ms`)), options.timeoutMs);
993
+ }) : null;
994
+ try {
995
+ const { stdout, stderr, exitCode } = timedOut ? await Promise.race([completion, timedOut]) : await completion;
996
+ if (exitCode !== 0) {
997
+ throw new Error(`git ${args.join(" ")} failed: ${stderr.trim() || stdout.trim() || `exit ${exitCode}`}`);
998
+ }
999
+ return stdout.trim();
1000
+ } catch (error) {
1001
+ if (error instanceof GitTransportTimeoutError) {
1002
+ void terminateProcessTree(subprocess, { graceMs: 0 }).catch(() => void 0);
1003
+ void completion.catch(() => void 0);
1004
+ }
1005
+ throw error;
1006
+ } finally {
1007
+ if (timer) clearTimeout(timer);
978
1008
  }
979
- return stdout.trim();
980
1009
  }
981
1010
  async function tryGitAsync(args, options = {}) {
982
1011
  try {
@@ -1837,14 +1866,13 @@ const workerGitSecurityTestHarness = {
1837
1866
  executeEditFileOperation,
1838
1867
  terminateCredentialBearingChildren,
1839
1868
  terminateCredentialBearingChildrenWithRetention,
1840
- async commitCredentialGeneration(prepared, credential, children, beforeMutation, previousGenerationFenced = false) {
1841
- return await commitCredentialGeneration(
1842
- prepared,
1843
- credential,
1844
- async () => await terminateCredentialBearingChildren(children),
1845
- beforeMutation,
1846
- previousGenerationFenced
1847
- );
1869
+ async commitCredentialGeneration(prepared, credential, options) {
1870
+ return await commitCredentialGeneration(prepared, credential, {
1871
+ secretMaterialChanged: options.secretMaterialChanged,
1872
+ terminatePreviousGeneration: async () => await terminateCredentialBearingChildren(options.children),
1873
+ beforeMutation: options.beforeMutation,
1874
+ previousGenerationFenced: options.previousGenerationFenced ?? false
1875
+ });
1848
1876
  },
1849
1877
  credentialGenerationReceiptPath,
1850
1878
  credentialAuthorityLockPath,
@@ -1882,14 +1910,14 @@ function installGitHubCredentialGeneration(credential) {
1882
1910
  `);
1883
1911
  }
1884
1912
  }
1885
- async function commitCredentialGeneration(prepared, credential, terminatePreviousGeneration, beforeMutation, previousGenerationFenced = false) {
1913
+ async function commitCredentialGeneration(prepared, credential, options) {
1886
1914
  const changed = prepared.changed;
1887
1915
  try {
1888
- if (changed && !previousGenerationFenced) {
1916
+ if (changed && options.secretMaterialChanged && !options.previousGenerationFenced) {
1889
1917
  githubCredential = null;
1890
- await terminatePreviousGeneration();
1918
+ await options.terminatePreviousGeneration();
1891
1919
  }
1892
- prepared.commit(beforeMutation);
1920
+ prepared.commit(options.beforeMutation);
1893
1921
  } catch (error) {
1894
1922
  prepared.discard();
1895
1923
  throw error instanceof RegistryAuthConfigurationError ? error : new RegistryAuthConfigurationError(error);
@@ -3705,6 +3733,12 @@ async function startWorker(options, projectRuntime = {
3705
3733
  projectRuntime.initializedWorkspaceState = initializedWorkspaceState;
3706
3734
  const projectWorkspaceStateStore = initializedWorkspaceState.store;
3707
3735
  let projectWorkspaceState = initializedWorkspaceState.store.read();
3736
+ const checkoutGarbageCollector = checkoutGarbageCollectorFor(projectsRoot);
3737
+ const staleStagedCheckouts = checkoutGarbageCollector.sweep();
3738
+ if (staleStagedCheckouts > 0) {
3739
+ process.stderr.write(`[r5d-worker] collecting ${staleStagedCheckouts} deleted checkout(s) left from an earlier run
3740
+ `);
3741
+ }
3708
3742
  const { projectConfigById, readyProjectIds, reconciledProjectConfigFingerprints } = projectRuntime;
3709
3743
  const pendingCheckouts = /* @__PURE__ */ new Map();
3710
3744
  const lastObservedProjectHeads = /* @__PURE__ */ new Map();
@@ -3819,6 +3853,7 @@ async function startWorker(options, projectRuntime = {
3819
3853
  `
3820
3854
  );
3821
3855
  }
3856
+ if (deleted.stagedForCollectionPath) checkoutGarbageCollector.enqueue(deleted.stagedForCollectionPath);
3822
3857
  }
3823
3858
  const planSourcePath = path.join(planRoot, project.projectId, ...branchName.split("/"));
3824
3859
  fs.rmSync(planSourcePath, { recursive: true, force: true });
@@ -3902,6 +3937,7 @@ async function startWorker(options, projectRuntime = {
3902
3937
  `
3903
3938
  );
3904
3939
  }
3940
+ if (deleted.stagedForCollectionPath) checkoutGarbageCollector.enqueue(deleted.stagedForCollectionPath);
3905
3941
  fs.rmSync(path.join(planRoot, project.projectId, ...branchName.split("/")), { recursive: true, force: true });
3906
3942
  readyProjectIds.delete(project.projectId);
3907
3943
  reconciledProjectConfigFingerprints.delete(project.projectId);
@@ -4338,33 +4374,56 @@ async function startWorker(options, projectRuntime = {
4338
4374
  return reconciledProjectIds;
4339
4375
  };
4340
4376
  const ORIGIN_DIVERGENCE_REFRESH_INTERVAL_MS = 5 * 60 * 1e3;
4377
+ const ORIGIN_DIVERGENCE_FETCH_TIMEOUT_MS = 3e4;
4378
+ const ORIGIN_DIVERGENCE_REFRESH_BUDGET_MS = 9e4;
4341
4379
  const lastOriginDivergenceFetchMs = /* @__PURE__ */ new Map();
4342
- const collectAheadOfOriginBranches = async (refetchOriginProjectIds) => {
4380
+ const collectAheadOfOriginBranches = async (refetchOriginProjectIds, onProgress) => {
4343
4381
  const aheadBranches = [];
4344
4382
  for (const projectId of [...lastOriginDivergenceFetchMs.keys()]) {
4345
4383
  if (!projectConfigById.has(projectId)) lastOriginDivergenceFetchMs.delete(projectId);
4346
4384
  }
4347
- for (const project of projectConfigById.values()) {
4385
+ const passStartedAtMs = Date.now();
4386
+ let budgetSpentReported = false;
4387
+ const projects = [...projectConfigById.values()];
4388
+ for (const [projectIndex, project] of projects.entries()) {
4348
4389
  if (project.executionDisabled || !readyProjectIds.has(project.projectId) || project.branches.length === 0) continue;
4390
+ onProgress?.({
4391
+ detail: `Checking origin divergence for ${project.projectPath}`,
4392
+ completedItems: projectIndex,
4393
+ totalItems: projects.length
4394
+ });
4349
4395
  const connection = projectConnection(project);
4350
4396
  const primaryPath = configuredProjectBranchPath(projectsRoot, project, primaryProjectBranch(project));
4351
4397
  const lastFetchedAtMs = lastOriginDivergenceFetchMs.get(project.projectId);
4352
4398
  if (refetchOriginProjectIds.has(project.projectId) || lastFetchedAtMs === void 0 || Date.now() - lastFetchedAtMs >= ORIGIN_DIVERGENCE_REFRESH_INTERVAL_MS) {
4353
- if (await tryGitAsync(
4354
- [
4355
- ...gitTransportSecurityArgs(connection.originUrl, connection.credentialHelper, connection.originCredentialUsername),
4356
- "fetch",
4357
- "--no-recurse-submodules",
4358
- "--prune",
4359
- "origin",
4360
- "+refs/heads/*:refs/remotes/origin/*"
4361
- ],
4362
- { cwd: primaryPath }
4363
- )) {
4364
- lastOriginDivergenceFetchMs.set(project.projectId, Date.now());
4399
+ if (Date.now() - passStartedAtMs >= ORIGIN_DIVERGENCE_REFRESH_BUDGET_MS) {
4400
+ if (!budgetSpentReported) {
4401
+ budgetSpentReported = true;
4402
+ process.stderr.write(
4403
+ `[r5d-worker] origin divergence refresh budget of ${ORIGIN_DIVERGENCE_REFRESH_BUDGET_MS}ms is spent; remaining origins keep their previous comparison until the next refresh
4404
+ `
4405
+ );
4406
+ }
4365
4407
  } else {
4366
- process.stderr.write(`[r5d-worker] could not refresh origin divergence for ${project.projectPath}
4367
- `);
4408
+ try {
4409
+ await runGitAsync(
4410
+ [
4411
+ ...gitTransportSecurityArgs(connection.originUrl, connection.credentialHelper, connection.originCredentialUsername),
4412
+ "fetch",
4413
+ "--no-recurse-submodules",
4414
+ "--prune",
4415
+ "origin",
4416
+ "+refs/heads/*:refs/remotes/origin/*"
4417
+ ],
4418
+ { cwd: primaryPath, timeoutMs: ORIGIN_DIVERGENCE_FETCH_TIMEOUT_MS }
4419
+ );
4420
+ lastOriginDivergenceFetchMs.set(project.projectId, Date.now());
4421
+ } catch (error) {
4422
+ process.stderr.write(
4423
+ `[r5d-worker] could not refresh origin divergence for ${project.projectPath}: ${error instanceof Error ? error.message : String(error)}
4424
+ `
4425
+ );
4426
+ }
4368
4427
  }
4369
4428
  }
4370
4429
  for (const branch of project.branches) {
@@ -5397,13 +5456,11 @@ async function startWorker(options, projectRuntime = {
5397
5456
  } catch (error) {
5398
5457
  throw error instanceof RegistryAuthConfigurationError ? error : new RegistryAuthConfigurationError(error);
5399
5458
  }
5400
- await commitCredentialGeneration(
5401
- preparedAuthGeneration,
5402
- message.githubCredential,
5403
- terminateActiveCredentialBearingChildren,
5404
- void 0,
5405
- credentialPublicationPreauthorized
5406
- );
5459
+ await commitCredentialGeneration(preparedAuthGeneration, message.githubCredential, {
5460
+ secretMaterialChanged: credentialTransitionPhase !== "current",
5461
+ terminatePreviousGeneration: terminateActiveCredentialBearingChildren,
5462
+ previousGenerationFenced: credentialPublicationPreauthorized
5463
+ });
5407
5464
  if (receiptGeneration !== workspaceConfigurationReceiptGeneration) {
5408
5465
  throw new Error("Workspace configuration was superseded by a newer server generation");
5409
5466
  }
@@ -5538,7 +5595,15 @@ async function startWorker(options, projectRuntime = {
5538
5595
  }
5539
5596
  };
5540
5597
  const hasDurableBranchDeletion = projectWorkspaceState.tombstones.some((tombstone) => tombstone.kind === "branch");
5541
- const result = await performWorkspaceSync({
5598
+ sendLifecycleProgress({
5599
+ phase: "configuring",
5600
+ operationId: configurationOperationId,
5601
+ detail: `Synchronizing the workspace after configuring ${message.projects.length} project(s)`,
5602
+ completedItems: message.projects.length,
5603
+ totalItems: message.projects.length
5604
+ });
5605
+ const connectSyncStartedAtMs = Date.now();
5606
+ const syncResult = await performWorkspaceSync({
5542
5607
  attemptId: crypto.randomUUID(),
5543
5608
  trigger: { type: "connect" },
5544
5609
  confirmedLargeDiff: hasDurableBranchDeletion,
@@ -5546,6 +5611,11 @@ async function startWorker(options, projectRuntime = {
5546
5611
  resetToCanonical: workspaceConfigurationResetToCanonicalIsAllowed(message.resetToCanonical, incidentDeferral.incidentId),
5547
5612
  assertStillAdmitted: assertConfigurationSyncStillAdmitted
5548
5613
  });
5614
+ const connectSyncMs = Date.now() - connectSyncStartedAtMs;
5615
+ const result = syncResult.telemetry ? syncResult : {
5616
+ ...syncResult,
5617
+ telemetry: { totalMs: connectSyncMs, queueMs: 0, prepareMs: lastWorkspaceSyncMirrorObservationMs, synchronizeMs: connectSyncMs }
5618
+ };
5549
5619
  assertConfigurationSyncStillAdmitted();
5550
5620
  if (receiptGeneration !== workspaceConfigurationReceiptGeneration) {
5551
5621
  throw new Error("Workspace configuration was superseded by a newer server generation");
@@ -5578,7 +5648,14 @@ async function startWorker(options, projectRuntime = {
5578
5648
  const pending = [...pendingCheckouts.values()].sort(
5579
5649
  (left, right) => left.projectId.localeCompare(right.projectId) || left.branchName.localeCompare(right.branchName)
5580
5650
  );
5581
- return { result, pending, aheadOfOriginBranches: await collectAheadOfOriginBranches(reconciledProjectIds) };
5651
+ return {
5652
+ result,
5653
+ pending,
5654
+ aheadOfOriginBranches: await collectAheadOfOriginBranches(
5655
+ reconciledProjectIds,
5656
+ (progress) => sendLifecycleProgress({ phase: "configuring", operationId: configurationOperationId, ...progress })
5657
+ )
5658
+ };
5582
5659
  });
5583
5660
  } catch (error) {
5584
5661
  if (receiptGeneration !== workspaceConfigurationReceiptGeneration) {
@@ -5795,6 +5872,7 @@ async function startWorker(options, projectRuntime = {
5795
5872
  workspaceIncidentConfigDeferralV1: true,
5796
5873
  workspaceConfigResetToCanonicalV1: true,
5797
5874
  projectBranchDeletionFastAckV1: true,
5875
+ projectBranchDeletionStagedRemovalV1: true,
5798
5876
  projectMirrorLeaseV1: true,
5799
5877
  projectMirrorRefsTokensV1: true,
5800
5878
  projectBranchWorkingTreeModeV1: true
@@ -5896,7 +5974,9 @@ async function startWorker(options, projectRuntime = {
5896
5974
  if (admission !== "new") {
5897
5975
  const response = recoveryStore.response(operationRequestId);
5898
5976
  if (response && admission !== "unknown") sendReplayWorkerMessage(ws, response);
5899
- return;
5977
+ if (!(admission === "unknown" && message.type === "delete_project_branch" && recoveryStore.readmitUnknownBranchDeletion(operationRequestId))) {
5978
+ return;
5979
+ }
5900
5980
  }
5901
5981
  }
5902
5982
  const messageAdmissionGeneration = workerAdmissionGeneration;
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.120",
3
+ "version": "0.0.122",
4
4
  "type": "module"
5
5
  }
@@ -0,0 +1,150 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ const PROJECT_DELETED_CHECKOUTS_DIRECTORY = ".r5d-deleted";
5
+ function isRealDirectory(candidate) {
6
+ try {
7
+ return fs.lstatSync(candidate).isDirectory();
8
+ } catch {
9
+ return false;
10
+ }
11
+ }
12
+ function stageProjectCheckoutForDeletion(projectRoot, branchName, checkoutPath) {
13
+ const garbageRoot = path.join(projectRoot, PROJECT_DELETED_CHECKOUTS_DIRECTORY);
14
+ if (!isRealDirectory(checkoutPath)) throw new Error(`Cannot stage ${checkoutPath} for deletion: not a directory`);
15
+ fs.mkdirSync(garbageRoot, { recursive: true });
16
+ if (!isRealDirectory(garbageRoot)) throw new Error(`Cannot stage ${checkoutPath} for deletion: ${garbageRoot} is not a directory`);
17
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
18
+ const target = path.join(garbageRoot, `${branchName.replace(/\//g, "__")}-${timestamp}-${randomUUID().slice(0, 8)}`);
19
+ fs.renameSync(checkoutPath, target);
20
+ return target;
21
+ }
22
+ function isStagedProjectCheckoutPath(projectsRoot, candidate) {
23
+ const relative = path.relative(path.resolve(projectsRoot), path.resolve(candidate));
24
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) return false;
25
+ const segments = relative.split(path.sep);
26
+ return segments.length === 4 && segments[2] === PROJECT_DELETED_CHECKOUTS_DIRECTORY && segments[3] !== "" && !segments[3].startsWith(".");
27
+ }
28
+ function ownedStagedPath(projectsRoot, candidate) {
29
+ const root = path.resolve(projectsRoot);
30
+ const resolved = path.resolve(candidate);
31
+ if (!isStagedProjectCheckoutPath(root, resolved)) return null;
32
+ let current = root;
33
+ for (const segment of path.relative(root, path.dirname(resolved)).split(path.sep)) {
34
+ current = path.join(current, segment);
35
+ if (!isRealDirectory(current)) return null;
36
+ }
37
+ let stat;
38
+ try {
39
+ stat = fs.lstatSync(resolved);
40
+ } catch {
41
+ return null;
42
+ }
43
+ if (stat.isSymbolicLink()) return { kind: "link" };
44
+ return stat.isDirectory() ? { kind: "directory" } : null;
45
+ }
46
+ function realDirectoryEntries(directory) {
47
+ if (!isRealDirectory(directory)) return [];
48
+ try {
49
+ return fs.readdirSync(directory, { withFileTypes: true }).map((entry) => entry.name).sort();
50
+ } catch {
51
+ return [];
52
+ }
53
+ }
54
+ function discoverStagedProjectCheckouts(projectsRoot) {
55
+ const root = path.resolve(projectsRoot);
56
+ const staged = [];
57
+ for (const namespace of realDirectoryEntries(root)) {
58
+ if (namespace.startsWith(".")) continue;
59
+ for (const project of realDirectoryEntries(path.join(root, namespace))) {
60
+ if (project.startsWith(".")) continue;
61
+ const garbageRoot = path.join(root, namespace, project, PROJECT_DELETED_CHECKOUTS_DIRECTORY);
62
+ for (const entry of realDirectoryEntries(garbageRoot)) {
63
+ const candidate = path.join(garbageRoot, entry);
64
+ if (ownedStagedPath(root, candidate)) staged.push(candidate);
65
+ }
66
+ }
67
+ }
68
+ return staged;
69
+ }
70
+ async function removeStagedProjectCheckout(stagedPath) {
71
+ if (fs.lstatSync(stagedPath).isSymbolicLink()) {
72
+ fs.unlinkSync(stagedPath);
73
+ return;
74
+ }
75
+ if (process.platform === "win32") {
76
+ await fs.promises.rm(stagedPath, { recursive: true, force: true });
77
+ return;
78
+ }
79
+ const command = ["rm", "-rf", "--", stagedPath];
80
+ if (Bun.which("nice")) command.unshift("nice", "-n", "19");
81
+ if (process.platform === "linux" && Bun.which("ionice")) command.unshift("ionice", "-c", "3");
82
+ const child = Bun.spawn(command, { stdin: "ignore", stdout: "ignore", stderr: "pipe" });
83
+ const [exitCode, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()]);
84
+ if (exitCode !== 0) throw new Error(`rm exited ${exitCode}: ${stderr.trim()}`);
85
+ if (fs.existsSync(stagedPath)) throw new Error("staged checkout still exists after removal");
86
+ }
87
+ class ProjectCheckoutGarbageCollector {
88
+ projectsRoot;
89
+ remove;
90
+ log;
91
+ queued = /* @__PURE__ */ new Set();
92
+ tail = Promise.resolve();
93
+ collectedCount = 0;
94
+ constructor(options) {
95
+ this.projectsRoot = path.resolve(options.projectsRoot);
96
+ this.remove = options.remove ?? removeStagedProjectCheckout;
97
+ this.log = options.log ?? ((message) => process.stderr.write(`${message}
98
+ `));
99
+ }
100
+ /** Number of removals that have completed successfully. */
101
+ get collected() {
102
+ return this.collectedCount;
103
+ }
104
+ /** Resolves once everything queued so far has been attempted. */
105
+ get idle() {
106
+ return this.tail;
107
+ }
108
+ enqueue(stagedPath) {
109
+ const resolved = path.resolve(stagedPath);
110
+ if (!ownedStagedPath(this.projectsRoot, resolved)) {
111
+ throw new Error(`Refusing to collect ${stagedPath}: not a staged checkout under ${this.projectsRoot}`);
112
+ }
113
+ if (this.queued.has(resolved)) return;
114
+ this.queued.add(resolved);
115
+ this.tail = this.tail.then(async () => {
116
+ try {
117
+ const owned = ownedStagedPath(this.projectsRoot, resolved);
118
+ if (owned) {
119
+ const startedAt = performance.now();
120
+ await this.remove(resolved);
121
+ this.collectedCount += 1;
122
+ this.log(`[r5d-worker] collected deleted checkout ${resolved} in ${Math.round(performance.now() - startedAt)}ms`);
123
+ } else if (fs.existsSync(resolved) || isRealDirectory(path.dirname(resolved))) {
124
+ this.log(`[r5d-worker] skipped deleted checkout ${resolved}: no longer an owned staged checkout`);
125
+ }
126
+ } catch (error) {
127
+ this.log(
128
+ `[r5d-worker] deleted checkout ${resolved} could not be collected yet: ${error instanceof Error ? error.message : String(error)}`
129
+ );
130
+ } finally {
131
+ this.queued.delete(resolved);
132
+ }
133
+ });
134
+ }
135
+ /** Enqueue every staged checkout left behind by an earlier process. */
136
+ sweep() {
137
+ const staged = discoverStagedProjectCheckouts(this.projectsRoot);
138
+ for (const stagedPath of staged) this.enqueue(stagedPath);
139
+ return staged.length;
140
+ }
141
+ }
142
+ export {
143
+ PROJECT_DELETED_CHECKOUTS_DIRECTORY,
144
+ ProjectCheckoutGarbageCollector,
145
+ discoverStagedProjectCheckouts,
146
+ isStagedProjectCheckoutPath,
147
+ ownedStagedPath,
148
+ removeStagedProjectCheckout,
149
+ stageProjectCheckoutForDeletion
150
+ };
@@ -4,6 +4,7 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { gitCredentialUsernameConfigKey, gitTransportSecurityArgs, workerGitProcessEnvironment } from "./git-process-environment.mjs";
6
6
  import { validateManagedBranchName } from "./managed-paths.mjs";
7
+ import { stageProjectCheckoutForDeletion } from "./project-checkout-garbage.mjs";
7
8
  import {
8
9
  canonicalizeLocalProjectMirrorRefs,
9
10
  parseProjectMirrorRefListing,
@@ -1290,6 +1291,7 @@ function deleteLinkedProjectBranch(input) {
1290
1291
  const primaryCommonDir = commonGitDirectory(primaryPath);
1291
1292
  if (!primaryCommonDir) throw new Error("Primary project checkout is unavailable");
1292
1293
  let movedAsidePath;
1294
+ let stagedForCollectionPath;
1293
1295
  if (fs.existsSync(checkoutPath)) {
1294
1296
  const checkoutCommonDir = fs.lstatSync(checkoutPath).isDirectory() ? commonGitDirectory(checkoutPath) : null;
1295
1297
  if (checkoutCommonDir !== primaryCommonDir) {
@@ -1299,7 +1301,8 @@ function deleteLinkedProjectBranch(input) {
1299
1301
  if (projectWorktreeOperationInProgress(checkoutPath)) {
1300
1302
  throw new Error(`Project branch ${input.branchName} has an in-progress Git operation`);
1301
1303
  }
1302
- git(primaryPath, ["worktree", "remove", "--force", checkoutPath], `remove linked worktree ${input.branchName}`);
1304
+ stagedForCollectionPath = stageProjectCheckoutForDeletion(input.projectRoot, input.branchName, checkoutPath);
1305
+ git(primaryPath, ["worktree", "prune"], `prune linked worktree ${input.branchName}`);
1303
1306
  }
1304
1307
  } else {
1305
1308
  tryGit(primaryPath, ["worktree", "prune"]);
@@ -1308,7 +1311,11 @@ function deleteLinkedProjectBranch(input) {
1308
1311
  if (tryGit(primaryPath, ["show-ref", "--verify", "--quiet", branchRef])) {
1309
1312
  git(primaryPath, ["branch", "-D", input.branchName], `delete project branch ${input.branchName}`);
1310
1313
  }
1311
- return { branchName: input.branchName, ...movedAsidePath ? { movedAsidePath } : {} };
1314
+ return {
1315
+ branchName: input.branchName,
1316
+ ...movedAsidePath ? { movedAsidePath } : {},
1317
+ ...stagedForCollectionPath ? { stagedForCollectionPath } : {}
1318
+ };
1312
1319
  }
1313
1320
  function removeProjectWorktrees(input) {
1314
1321
  const projectRoot = path.resolve(input.projectRoot);
@@ -64,6 +64,19 @@ class WorkerRecoveryStore {
64
64
  isUnknown(requestId) {
65
65
  return this.row(requestId)?.state === "unknown";
66
66
  }
67
+ /**
68
+ * Re-admit an operation whose crash outcome is unknown so it can run again
69
+ * and record a result. Only a branch deletion qualifies: it is idempotent
70
+ * for its exact incarnation (durable tombstone plus incarnation preflight),
71
+ * so a rerun converges on the same outcome. Every other unknown operation
72
+ * keeps the no-replay invariant.
73
+ */
74
+ readmitUnknownBranchDeletion(requestId) {
75
+ const row = this.row(requestId);
76
+ if (!row || row.state !== "unknown" || row.request_type !== "delete_project_branch") return false;
77
+ this.db.run("UPDATE operations SET state='accepted', response=NULL WHERE request_id=?", [row.request_id]);
78
+ return true;
79
+ }
67
80
  unknown(requestId) {
68
81
  const row = this.row(requestId);
69
82
  if (row) this.db.run("UPDATE operations SET state='unknown' WHERE request_id=?", [row.request_id]);
@@ -2330,7 +2330,9 @@ async function runWorkspaceGitSynchronization(input, remoteHeadFetches) {
2330
2330
  }
2331
2331
  restoreDeferredWorkspaceMountsFromHead(
2332
2332
  workspacePath,
2333
- input.mounts.filter(({ id }) => deferredMountIds.has(id))
2333
+ input.mounts.filter(
2334
+ (mount) => deferredMountIds.has(mount.id) || mount.preserveLocalOnHydrationBasisChange === true && cycleSkippedMountIds.has(mount.id)
2335
+ )
2334
2336
  );
2335
2337
  await stageAndCommitWorkspace();
2336
2338
  await fsyncWorkspaceCheckoutTreeYielding(workspacePath);
@@ -957,10 +957,15 @@ export declare const workerGitSecurityTestHarness: {
957
957
  executeEditFileOperation: typeof executeEditFileOperation;
958
958
  terminateCredentialBearingChildren: typeof terminateCredentialBearingChildren;
959
959
  terminateCredentialBearingChildrenWithRetention: typeof terminateCredentialBearingChildrenWithRetention;
960
- commitCredentialGeneration(prepared: PreparedPrivateAuthFileGeneration, credential: WorkerGitHubCredential | null, children: readonly {
961
- label: string;
962
- terminate: () => void | Promise<void>;
963
- }[], beforeMutation?: (filePath: string, index: number) => void, previousGenerationFenced?: boolean): Promise<boolean>;
960
+ commitCredentialGeneration(prepared: PreparedPrivateAuthFileGeneration, credential: WorkerGitHubCredential | null, options: {
961
+ children: readonly {
962
+ label: string;
963
+ terminate: () => void | Promise<void>;
964
+ }[];
965
+ secretMaterialChanged: boolean;
966
+ beforeMutation?: (filePath: string, index: number) => void;
967
+ previousGenerationFenced?: boolean;
968
+ }): Promise<boolean>;
964
969
  credentialGenerationReceiptPath: typeof credentialGenerationReceiptPath;
965
970
  credentialAuthorityLockPath: typeof credentialAuthorityLockPath;
966
971
  acquireCredentialAuthorityLock: typeof acquireCredentialAuthorityLock;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Where a deleted branch checkout waits for collection. Staging is the only
3
+ * platform write into this directory and a live checkout always lives at
4
+ * `<projectRoot>/<branch>`, so a crash between staging and collection leaves
5
+ * nothing that a later sweep could confuse with a replacement branch. The
6
+ * filesystem is user-managed, though, so the collector never trusts the name
7
+ * alone: it removes only real directories or leaf links that sit under a real
8
+ * garbage directory of a real project directory (see `ownedStagedPath`).
9
+ * Distinct from `.r5d-removed`, which preserves paths that were not this
10
+ * project's worktree.
11
+ */
12
+ export declare const PROJECT_DELETED_CHECKOUTS_DIRECTORY = ".r5d-deleted";
13
+ /**
14
+ * Rename a checkout this project owns out of the active namespace. One
15
+ * same-filesystem rename takes milliseconds whatever the tree holds, so the
16
+ * branch mutation lease is released long before the bytes are gone. The
17
+ * garbage directory must be a real directory: a planted link would make the
18
+ * later collection walk through it, so staging refuses rather than continue.
19
+ */
20
+ export declare function stageProjectCheckoutForDeletion(projectRoot: string, branchName: string, checkoutPath: string): string;
21
+ /** True when `candidate` is lexically a direct entry of some project's garbage directory under `projectsRoot`. */
22
+ export declare function isStagedProjectCheckoutPath(projectsRoot: string, candidate: string): boolean;
23
+ /**
24
+ * Whether a lexically staged path is safe to remove right now: every ancestor
25
+ * below the projects root is a real directory (a linked namespace, project,
26
+ * or garbage directory would let a removal reach an unrelated tree through
27
+ * the link), and the entry itself is a real directory or a leaf link. Checked
28
+ * again immediately before the removal starts, never only at enqueue time.
29
+ */
30
+ export declare function ownedStagedPath(projectsRoot: string, candidate: string): {
31
+ kind: "directory" | "link";
32
+ } | null;
33
+ /** Every owned staged checkout under `<projectsRoot>/<namespace>/<project>/.r5d-deleted/`; linked ancestors are not followed. */
34
+ export declare function discoverStagedProjectCheckouts(projectsRoot: string): string[];
35
+ /**
36
+ * Delete a staged tree in a child process so a large recursive removal never
37
+ * runs on the worker event loop, at the lowest CPU and (where supported) I/O
38
+ * priority so live agents on the same volume keep their share. A leaf link is
39
+ * unlinked in place; its target is never followed.
40
+ */
41
+ export declare function removeStagedProjectCheckout(stagedPath: string): Promise<void>;
42
+ type Logger = (message: string) => void;
43
+ /**
44
+ * Collects staged checkouts one at a time, outside every workspace lease.
45
+ * Entries are enqueued right after staging and by `sweep()` at worker start,
46
+ * so a crash at any point converges on the next start. A failed removal is
47
+ * logged and left for the next sweep.
48
+ */
49
+ export declare class ProjectCheckoutGarbageCollector {
50
+ private readonly projectsRoot;
51
+ private readonly remove;
52
+ private readonly log;
53
+ private readonly queued;
54
+ private tail;
55
+ private collectedCount;
56
+ constructor(options: {
57
+ projectsRoot: string;
58
+ remove?: (stagedPath: string) => Promise<void>;
59
+ log?: Logger;
60
+ });
61
+ /** Number of removals that have completed successfully. */
62
+ get collected(): number;
63
+ /** Resolves once everything queued so far has been attempted. */
64
+ get idle(): Promise<void>;
65
+ enqueue(stagedPath: string): void;
66
+ /** Enqueue every staged checkout left behind by an earlier process. */
67
+ sweep(): number;
68
+ }
69
+ export {};
@@ -209,6 +209,12 @@ export declare function deleteLinkedProjectBranch(input: {
209
209
  branchName: string;
210
210
  /** Where a path that was not this project's linked worktree was set aside instead of being deleted. */
211
211
  movedAsidePath?: string;
212
+ /**
213
+ * Where the linked worktree's tree now waits for garbage collection. The
214
+ * branch is already gone from Git; the caller hands this path to the
215
+ * collector so the recursive removal runs outside every workspace lease.
216
+ */
217
+ stagedForCollectionPath?: string;
212
218
  };
213
219
  export declare function removeProjectWorktrees(input: {
214
220
  projectRoot: string;
@@ -16,6 +16,14 @@ export declare class WorkerRecoveryStore {
16
16
  requestId: string;
17
17
  }): "new" | "duplicate" | "unknown" | "conflict";
18
18
  isUnknown(requestId: string): boolean;
19
+ /**
20
+ * Re-admit an operation whose crash outcome is unknown so it can run again
21
+ * and record a result. Only a branch deletion qualifies: it is idempotent
22
+ * for its exact incarnation (durable tombstone plus incarnation preflight),
23
+ * so a rerun converges on the same outcome. Every other unknown operation
24
+ * keeps the no-replay invariant.
25
+ */
26
+ readmitUnknownBranchDeletion(requestId: string): boolean;
19
27
  unknown(requestId: string): void;
20
28
  unknownRun(runId: string): void;
21
29
  record(message: Message): Message;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.120",
3
+ "version": "0.0.122",
4
4
  "type": "module",
5
5
  "main": "./dist/cjs/main.cjs",
6
6
  "module": "./dist/mjs/main.mjs",