@ricsam/r5d-worker 0.0.121 → 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);
@@ -3704,6 +3714,12 @@ async function startWorker(options, projectRuntime = {
3704
3714
  projectRuntime.initializedWorkspaceState = initializedWorkspaceState;
3705
3715
  const projectWorkspaceStateStore = initializedWorkspaceState.store;
3706
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
+ }
3707
3723
  const { projectConfigById, readyProjectIds, reconciledProjectConfigFingerprints } = projectRuntime;
3708
3724
  const pendingCheckouts = /* @__PURE__ */ new Map();
3709
3725
  const lastObservedProjectHeads = /* @__PURE__ */ new Map();
@@ -3818,6 +3834,7 @@ async function startWorker(options, projectRuntime = {
3818
3834
  `
3819
3835
  );
3820
3836
  }
3837
+ if (deleted.stagedForCollectionPath) checkoutGarbageCollector.enqueue(deleted.stagedForCollectionPath);
3821
3838
  }
3822
3839
  const planSourcePath = import_node_path.default.join(planRoot, project.projectId, ...branchName.split("/"));
3823
3840
  import_node_fs.default.rmSync(planSourcePath, { recursive: true, force: true });
@@ -3901,6 +3918,7 @@ async function startWorker(options, projectRuntime = {
3901
3918
  `
3902
3919
  );
3903
3920
  }
3921
+ if (deleted.stagedForCollectionPath) checkoutGarbageCollector.enqueue(deleted.stagedForCollectionPath);
3904
3922
  import_node_fs.default.rmSync(import_node_path.default.join(planRoot, project.projectId, ...branchName.split("/")), { recursive: true, force: true });
3905
3923
  readyProjectIds.delete(project.projectId);
3906
3924
  reconciledProjectConfigFingerprints.delete(project.projectId);
@@ -5835,6 +5853,7 @@ async function startWorker(options, projectRuntime = {
5835
5853
  workspaceIncidentConfigDeferralV1: true,
5836
5854
  workspaceConfigResetToCanonicalV1: true,
5837
5855
  projectBranchDeletionFastAckV1: true,
5856
+ projectBranchDeletionStagedRemovalV1: true,
5838
5857
  projectMirrorLeaseV1: true,
5839
5858
  projectMirrorRefsTokensV1: true,
5840
5859
  projectBranchWorkingTreeModeV1: true
@@ -5936,7 +5955,9 @@ async function startWorker(options, projectRuntime = {
5936
5955
  if (admission !== "new") {
5937
5956
  const response = recoveryStore.response(operationRequestId);
5938
5957
  if (response && admission !== "unknown") sendReplayWorkerMessage(ws, response);
5939
- return;
5958
+ if (!(admission === "unknown" && message.type === "delete_project_branch" && recoveryStore.readmitUnknownBranchDeletion(operationRequestId))) {
5959
+ return;
5960
+ }
5940
5961
  }
5941
5962
  }
5942
5963
  const messageAdmissionGeneration = workerAdmissionGeneration;
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.121",
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]);
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);
@@ -3723,6 +3733,12 @@ async function startWorker(options, projectRuntime = {
3723
3733
  projectRuntime.initializedWorkspaceState = initializedWorkspaceState;
3724
3734
  const projectWorkspaceStateStore = initializedWorkspaceState.store;
3725
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
+ }
3726
3742
  const { projectConfigById, readyProjectIds, reconciledProjectConfigFingerprints } = projectRuntime;
3727
3743
  const pendingCheckouts = /* @__PURE__ */ new Map();
3728
3744
  const lastObservedProjectHeads = /* @__PURE__ */ new Map();
@@ -3837,6 +3853,7 @@ async function startWorker(options, projectRuntime = {
3837
3853
  `
3838
3854
  );
3839
3855
  }
3856
+ if (deleted.stagedForCollectionPath) checkoutGarbageCollector.enqueue(deleted.stagedForCollectionPath);
3840
3857
  }
3841
3858
  const planSourcePath = path.join(planRoot, project.projectId, ...branchName.split("/"));
3842
3859
  fs.rmSync(planSourcePath, { recursive: true, force: true });
@@ -3920,6 +3937,7 @@ async function startWorker(options, projectRuntime = {
3920
3937
  `
3921
3938
  );
3922
3939
  }
3940
+ if (deleted.stagedForCollectionPath) checkoutGarbageCollector.enqueue(deleted.stagedForCollectionPath);
3923
3941
  fs.rmSync(path.join(planRoot, project.projectId, ...branchName.split("/")), { recursive: true, force: true });
3924
3942
  readyProjectIds.delete(project.projectId);
3925
3943
  reconciledProjectConfigFingerprints.delete(project.projectId);
@@ -5854,6 +5872,7 @@ async function startWorker(options, projectRuntime = {
5854
5872
  workspaceIncidentConfigDeferralV1: true,
5855
5873
  workspaceConfigResetToCanonicalV1: true,
5856
5874
  projectBranchDeletionFastAckV1: true,
5875
+ projectBranchDeletionStagedRemovalV1: true,
5857
5876
  projectMirrorLeaseV1: true,
5858
5877
  projectMirrorRefsTokensV1: true,
5859
5878
  projectBranchWorkingTreeModeV1: true
@@ -5955,7 +5974,9 @@ async function startWorker(options, projectRuntime = {
5955
5974
  if (admission !== "new") {
5956
5975
  const response = recoveryStore.response(operationRequestId);
5957
5976
  if (response && admission !== "unknown") sendReplayWorkerMessage(ws, response);
5958
- return;
5977
+ if (!(admission === "unknown" && message.type === "delete_project_branch" && recoveryStore.readmitUnknownBranchDeletion(operationRequestId))) {
5978
+ return;
5979
+ }
5959
5980
  }
5960
5981
  }
5961
5982
  const messageAdmissionGeneration = workerAdmissionGeneration;
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.121",
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]);
@@ -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.121",
3
+ "version": "0.0.122",
4
4
  "type": "module",
5
5
  "main": "./dist/cjs/main.cjs",
6
6
  "module": "./dist/mjs/main.mjs",