@ricsam/r5d-worker 0.0.121 → 0.0.123

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/mjs/main.mjs CHANGED
@@ -25,6 +25,13 @@ 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";
29
+ import {
30
+ assertControlCommandArgv,
31
+ CONTROL_COMMAND_RUNTIME_MS,
32
+ controlCommandEnvironment,
33
+ resolveControlCommandExecutable
34
+ } from "./control-command-policy.mjs";
28
35
  import { createPtyOutputCoalescer } from "./pty-output-coalescer.mjs";
29
36
  import { openWorkerPortForwardRelay } from "./port-forward-client.mjs";
30
37
  import {
@@ -176,6 +183,26 @@ const PTY_FOREGROUND_POLL_MS = 1e3;
176
183
  const PTY_FOREGROUND_IDLE_ENABLED = process.env.R5D_PTY_FOREGROUND_IDLE !== "0";
177
184
  const PTY_TMP_PATH_PREFIX = "r5d-worker-tmp://";
178
185
  let workerCommandLauncher;
186
+ const trustedControlPath = process.env.PATH;
187
+ let capacityReportTimer;
188
+ let capacityReportSocket = null;
189
+ let lastCapacityReport;
190
+ function scheduleCapacityReport(force = false) {
191
+ if (force) lastCapacityReport = void 0;
192
+ if (capacityReportTimer) return;
193
+ capacityReportTimer = setTimeout(() => {
194
+ capacityReportTimer = void 0;
195
+ const ws = capacityReportSocket;
196
+ if (!ws || ws.readyState !== WebSocket.OPEN || !workerCommandLauncher) return;
197
+ const capacity = { ...workerCommandLauncher.capacityReport(), reportedAt: (/* @__PURE__ */ new Date()).toISOString() };
198
+ const { reportedAt: _reportedAt, ...comparable } = capacity;
199
+ const serialized = JSON.stringify(comparable);
200
+ if (serialized === lastCapacityReport) return;
201
+ lastCapacityReport = serialized;
202
+ sendWorkerMessage(ws, { type: "capacity_report", capacity });
203
+ }, 100);
204
+ capacityReportTimer.unref?.();
205
+ }
179
206
  const activeProcesses = /* @__PURE__ */ new Map();
180
207
  const credentialBearingProcessGroups = /* @__PURE__ */ new Map();
181
208
  const credentialBearingProcessGroupTargets = /* @__PURE__ */ new Map();
@@ -293,6 +320,15 @@ let workerAdmissionGeneration = 0;
293
320
  const workspaceMutationGate = new WorkspaceMutationGate();
294
321
  let workspaceSyncQueue = Promise.resolve();
295
322
  let startupProjectSnapshotRecoveryCompleted = false;
323
+ const checkoutGarbageCollectors = /* @__PURE__ */ new Map();
324
+ function checkoutGarbageCollectorFor(projectsRoot) {
325
+ let collector = checkoutGarbageCollectors.get(projectsRoot);
326
+ if (!collector) {
327
+ collector = new ProjectCheckoutGarbageCollector({ projectsRoot });
328
+ checkoutGarbageCollectors.set(projectsRoot, collector);
329
+ }
330
+ return collector;
331
+ }
296
332
  const workspaceSyncSingleFlight = {
297
333
  runExclusive(operation) {
298
334
  const queued = workspaceMutationGate.runSync(operation);
@@ -2784,6 +2820,10 @@ function cancelWorkerCommandLaunch(resources) {
2784
2820
  });
2785
2821
  }
2786
2822
  async function acquireWorkerCommandLaunch(ws, message, assertAdmission) {
2823
+ if (message.type === "exec_start" && message.commandClass === "control") {
2824
+ assertControlCommandArgv(message.argv);
2825
+ if (message.interactive) throw new Error("Control commands cannot be interactive");
2826
+ }
2787
2827
  const id = message.type === "pty_open" ? `pty:${message.ptyId}` : message.runId;
2788
2828
  const operationId = message.type === "pty_open" ? id : `exec:${id}`;
2789
2829
  sendWorkerMessage(ws, { type: "heartbeat_lease", operationId, active: true });
@@ -2792,6 +2832,7 @@ async function acquireWorkerCommandLaunch(ws, message, assertAdmission) {
2792
2832
  id,
2793
2833
  kind: message.type,
2794
2834
  ..."workspaceEffect" in message && message.workspaceEffect === "none" ? { workspaceEffect: "none" } : {},
2835
+ ...message.type === "exec_start" && message.commandClass === "control" ? { commandClass: "control" } : {},
2795
2836
  ..."sessionId" in message && message.sessionId ? { sessionId: message.sessionId } : {},
2796
2837
  assertAdmission: () => {
2797
2838
  assertAdmission();
@@ -2939,8 +2980,17 @@ async function executeStreamingCommand(input) {
2939
2980
  });
2940
2981
  const cwd = resolveCommandCwd(input.resolvedTarget.rootPath, input.message.cwd);
2941
2982
  const interactive = input.message.interactive === true;
2983
+ const control = input.message.commandClass === "control";
2984
+ let argv = input.message.argv;
2985
+ let environment = workerChildProcessEnvironment([githubProcessEnv(), input.message.env ?? {}, targetProcessEnv2]);
2986
+ if (control) {
2987
+ assertControlCommandArgv(argv);
2988
+ if (interactive) throw new Error("Control commands cannot be interactive");
2989
+ argv = [resolveControlCommandExecutable(trustedControlPath, (program, options) => Bun.which(program, options)), ...argv.slice(1)];
2990
+ environment = controlCommandEnvironment(environment, trustedControlPath);
2991
+ }
2942
2992
  input.assertAdmission();
2943
- const subprocess = Bun.spawn(input.resources.wrap(input.message.argv), {
2993
+ const subprocess = Bun.spawn(input.resources.wrap(argv), {
2944
2994
  cwd,
2945
2995
  // Without an explicit stdin the process reads /dev/null and interactive
2946
2996
  // prompts see immediate EOF; "pipe" keeps stdin open for exec_stdin.
@@ -2948,7 +2998,7 @@ async function executeStreamingCommand(input) {
2948
2998
  stdout: "pipe",
2949
2999
  stderr: "pipe",
2950
3000
  detached: true,
2951
- env: workerChildProcessEnvironment([githubProcessEnv(), input.message.env ?? {}, targetProcessEnv2])
3001
+ env: environment
2952
3002
  });
2953
3003
  spawnedProcess = subprocess;
2954
3004
  credentialBearingProcessGroups.set(subprocess.pid, subprocess);
@@ -2963,12 +3013,13 @@ async function executeStreamingCommand(input) {
2963
3013
  pid: subprocess.pid,
2964
3014
  processGroupId: process.platform === "win32" ? void 0 : subprocess.pid,
2965
3015
  credentialId: input.message.credentialId,
2966
- argv: input.message.argv,
3016
+ argv,
2967
3017
  command: input.message.command,
2968
3018
  cwd,
2969
3019
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
2970
3020
  ...interactive ? { interactive: true, stdin: subprocess.stdin } : {},
2971
- ...input.message.workspaceEffect === "none" ? { workspaceEffect: "none" } : {}
3021
+ ...input.message.workspaceEffect === "none" ? { workspaceEffect: "none" } : {},
3022
+ ...control ? { commandClass: "control" } : {}
2972
3023
  });
2973
3024
  settlePreparation();
2974
3025
  started = true;
@@ -2980,7 +3031,8 @@ async function executeStreamingCommand(input) {
2980
3031
  pid: subprocess.pid,
2981
3032
  ...process.platform === "win32" ? {} : { processGroupId: subprocess.pid }
2982
3033
  });
2983
- if (input.message.timeoutMs) {
3034
+ const timeoutMs = control ? Math.min(input.message.timeoutMs ?? CONTROL_COMMAND_RUNTIME_MS, CONTROL_COMMAND_RUNTIME_MS) : input.message.timeoutMs;
3035
+ if (timeoutMs) {
2984
3036
  timeout = setTimeout(() => {
2985
3037
  timedOut = true;
2986
3038
  cancelWorkerCommandLaunch(input.resources);
@@ -2991,7 +3043,7 @@ async function executeStreamingCommand(input) {
2991
3043
  `
2992
3044
  );
2993
3045
  });
2994
- }, input.message.timeoutMs);
3046
+ }, timeoutMs);
2995
3047
  }
2996
3048
  const [exitCode] = await Promise.all([
2997
3049
  subprocess.exited,
@@ -3154,7 +3206,8 @@ function buildActiveProcessReports() {
3154
3206
  ...active.cwd ? { cwd: active.cwd } : {},
3155
3207
  startedAt: active.startedAt,
3156
3208
  ...active.interactive ? { interactive: true } : {},
3157
- ...active.workspaceEffect === "none" ? { workspaceEffect: "none" } : {}
3209
+ ...active.workspaceEffect === "none" ? { workspaceEffect: "none" } : {},
3210
+ ...active.commandClass ? { commandClass: active.commandClass } : {}
3158
3211
  }));
3159
3212
  }
3160
3213
  function sendActiveProcessReport(ws) {
@@ -3676,6 +3729,7 @@ async function startWorker(options, projectRuntime = {
3676
3729
  validateLabel(label);
3677
3730
  if (!workerCommandLauncher) {
3678
3731
  const launcher = await createWorkerCommandLauncher({
3732
+ onCapacityChange: () => scheduleCapacityReport(),
3679
3733
  configuration: process.env.R5D_WORKER_COMMAND_LAUNCHER,
3680
3734
  env: commandLauncherEnvironment(),
3681
3735
  onFailure: handleWorkerCommandLauncherFailure
@@ -3723,6 +3777,12 @@ async function startWorker(options, projectRuntime = {
3723
3777
  projectRuntime.initializedWorkspaceState = initializedWorkspaceState;
3724
3778
  const projectWorkspaceStateStore = initializedWorkspaceState.store;
3725
3779
  let projectWorkspaceState = initializedWorkspaceState.store.read();
3780
+ const checkoutGarbageCollector = checkoutGarbageCollectorFor(projectsRoot);
3781
+ const staleStagedCheckouts = checkoutGarbageCollector.sweep();
3782
+ if (staleStagedCheckouts > 0) {
3783
+ process.stderr.write(`[r5d-worker] collecting ${staleStagedCheckouts} deleted checkout(s) left from an earlier run
3784
+ `);
3785
+ }
3726
3786
  const { projectConfigById, readyProjectIds, reconciledProjectConfigFingerprints } = projectRuntime;
3727
3787
  const pendingCheckouts = /* @__PURE__ */ new Map();
3728
3788
  const lastObservedProjectHeads = /* @__PURE__ */ new Map();
@@ -3837,6 +3897,7 @@ async function startWorker(options, projectRuntime = {
3837
3897
  `
3838
3898
  );
3839
3899
  }
3900
+ if (deleted.stagedForCollectionPath) checkoutGarbageCollector.enqueue(deleted.stagedForCollectionPath);
3840
3901
  }
3841
3902
  const planSourcePath = path.join(planRoot, project.projectId, ...branchName.split("/"));
3842
3903
  fs.rmSync(planSourcePath, { recursive: true, force: true });
@@ -3920,6 +3981,7 @@ async function startWorker(options, projectRuntime = {
3920
3981
  `
3921
3982
  );
3922
3983
  }
3984
+ if (deleted.stagedForCollectionPath) checkoutGarbageCollector.enqueue(deleted.stagedForCollectionPath);
3923
3985
  fs.rmSync(path.join(planRoot, project.projectId, ...branchName.split("/")), { recursive: true, force: true });
3924
3986
  readyProjectIds.delete(project.projectId);
3925
3987
  reconciledProjectConfigFingerprints.delete(project.projectId);
@@ -5854,6 +5916,8 @@ async function startWorker(options, projectRuntime = {
5854
5916
  workspaceIncidentConfigDeferralV1: true,
5855
5917
  workspaceConfigResetToCanonicalV1: true,
5856
5918
  projectBranchDeletionFastAckV1: true,
5919
+ projectBranchDeletionStagedRemovalV1: true,
5920
+ commandControlLaneV1: workerCommandLauncher?.supportsControlLane === true,
5857
5921
  projectMirrorLeaseV1: true,
5858
5922
  projectMirrorRefsTokensV1: true,
5859
5923
  projectBranchWorkingTreeModeV1: true
@@ -5955,7 +6019,9 @@ async function startWorker(options, projectRuntime = {
5955
6019
  if (admission !== "new") {
5956
6020
  const response = recoveryStore.response(operationRequestId);
5957
6021
  if (response && admission !== "unknown") sendReplayWorkerMessage(ws, response);
5958
- return;
6022
+ if (!(admission === "unknown" && message.type === "delete_project_branch" && recoveryStore.readmitUnknownBranchDeletion(operationRequestId))) {
6023
+ return;
6024
+ }
5959
6025
  }
5960
6026
  }
5961
6027
  const messageAdmissionGeneration = workerAdmissionGeneration;
@@ -5978,6 +6044,8 @@ async function startWorker(options, projectRuntime = {
5978
6044
  });
5979
6045
  };
5980
6046
  if (message.type === "connected") {
6047
+ capacityReportSocket = ws;
6048
+ scheduleCapacityReport(true);
5981
6049
  return;
5982
6050
  }
5983
6051
  if (message.type === "project_mirror_refs_tokens") {
@@ -6733,8 +6801,10 @@ async function startWorker(options, projectRuntime = {
6733
6801
  const runCommand = async () => {
6734
6802
  try {
6735
6803
  const resolvedTarget = resolveMessageTarget(message.target);
6736
- process.stdout.write(`[r5d-worker] exec_start ${message.runId}: ${message.argv.join(" ")}
6737
- `);
6804
+ process.stdout.write(
6805
+ `[r5d-worker] exec_start ${message.runId}${message.commandClass === "control" ? " (control)" : ""}: ${message.argv.join(" ")}
6806
+ `
6807
+ );
6738
6808
  await executeStreamingCommand({
6739
6809
  resources,
6740
6810
  ws,
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.121",
3
+ "version": "0.0.123",
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]);
@@ -2,9 +2,42 @@ export type CommandLaunchRequest = {
2
2
  id: string;
3
3
  kind: "exec" | "exec_start" | "pty_open";
4
4
  workspaceEffect?: "none";
5
+ /** An explicitly classified short coordination command; the launcher keeps a bounded lane for these. */
6
+ commandClass?: "control";
5
7
  sessionId?: string;
6
8
  assertAdmission: () => void;
7
9
  };
10
+ export type CommandLaunchLane = "general" | "utility" | "control";
11
+ export type CommandLaunchCapacity = {
12
+ generalSlots: number;
13
+ utilitySlots: number;
14
+ controlSlots: number;
15
+ controlRuntimeMs: number;
16
+ queueTimeoutMs: number;
17
+ };
18
+ export type CommandLaunchLaneReport = {
19
+ /** Null when no launcher bounds this lane (direct execution). */
20
+ slots: number | null;
21
+ active: Array<{
22
+ id: string;
23
+ sessionId?: string;
24
+ since: string;
25
+ }>;
26
+ queued: Array<{
27
+ id: string;
28
+ sessionId?: string;
29
+ since: string;
30
+ }>;
31
+ };
32
+ /** Who holds and who waits for each lane, as the worker sees its own admissions. */
33
+ export type CommandLaunchCapacityReport = {
34
+ /** False for a direct worker: nothing is bounded and nothing can be starved. */
35
+ bounded: boolean;
36
+ controlRuntimeMs: number | null;
37
+ lanes: Record<CommandLaunchLane, CommandLaunchLaneReport>;
38
+ };
39
+ /** The same lane rule the managed launcher applies; kept here so reports match admissions. */
40
+ export declare function commandLaunchLane(request: Pick<CommandLaunchRequest, "kind" | "workspaceEffect" | "commandClass">): CommandLaunchLane;
8
41
  export type CommandLaunchLease = {
9
42
  wrap(argv: string[]): string[];
10
43
  cancel(): Promise<void>;
@@ -33,6 +66,7 @@ export declare class WorkerCommandLauncher {
33
66
  private initialized;
34
67
  private closePromise?;
35
68
  private sequence;
69
+ private capacity?;
36
70
  constructor(options?: {
37
71
  argv?: string[];
38
72
  env?: NodeJS.ProcessEnv;
@@ -40,9 +74,17 @@ export declare class WorkerCommandLauncher {
40
74
  onStderr?: (text: string) => void;
41
75
  requestTimeoutMs?: number;
42
76
  acquisitionTimeoutMs?: number;
77
+ /** Called after any admission changes lane, phase, or is released. */
78
+ onCapacityChange?: () => void;
43
79
  });
44
80
  initialize(): Promise<void>;
45
81
  get activeIds(): string[];
82
+ /** The lane totals the launcher declared at initialization; undefined for a direct worker or an older launcher. */
83
+ get declaredCapacity(): CommandLaunchCapacity | undefined;
84
+ get supportsControlLane(): boolean;
85
+ /** Current admissions per lane, for the worker's capacity report. */
86
+ capacityReport(): CommandLaunchCapacityReport;
87
+ private notifyCapacityChange;
46
88
  acquire(request: CommandLaunchRequest): Promise<CommandLaunchLease>;
47
89
  cancel(id: string): Promise<void>;
48
90
  cancelSession(sessionId: string): Promise<void>;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Which agent shell invocations may run in the managed launcher's bounded
3
+ * `control` lane: exactly one plain `r5dctl` coordination/diagnosis command.
4
+ *
5
+ * The lane exists so an agent can inspect capacity, message another session,
6
+ * or stop its own runs while every general slot is held by long work
7
+ * (incident 9). It is bounded (small memory, few PIDs, a runtime limit), so
8
+ * only commands that are short HTTP calls to the platform qualify. The
9
+ * classifier fails closed: anything the shell would interpret (operators,
10
+ * substitutions, expansions, quoting the tokenizer cannot prove literal,
11
+ * environment assignments, wrapper programs, paths) is refused and keeps its
12
+ * ordinary general-lane shell semantics. The resulting argv is validated a
13
+ * second time by the worker before it is spawned without a shell.
14
+ */
15
+ export declare const CONTROL_COMMAND_PROGRAM = "r5dctl";
16
+ /** Worker-side runtime bound for a control run; the launcher enforces its own limit as a backstop. */
17
+ export declare const CONTROL_COMMAND_RUNTIME_MS = 120000;
18
+ export declare const CONTROL_COMMAND_MAX_ARGV = 64;
19
+ export declare const CONTROL_COMMAND_MAX_LENGTH = 4096;
20
+ export type ControlCommandClassification = {
21
+ control: true;
22
+ argv: string[];
23
+ } | {
24
+ control: false;
25
+ reason: string;
26
+ };
27
+ /**
28
+ * Validate an argv as an allowed r5dctl coordination invocation. Used on the
29
+ * server after tokenizing, and again on the worker before spawning.
30
+ */
31
+ export declare function assertControlCommandArgv(argv: readonly string[]): void;
32
+ /**
33
+ * Locate the installed CLI through the worker's own, operator-controlled
34
+ * search path. The command's cwd, its environment, and any checkout-local
35
+ * shim play no part, so a project cannot substitute the executable the
36
+ * reserved lane runs.
37
+ */
38
+ export declare function resolveControlCommandExecutable(trustedPath: string | undefined, which: (program: string, options: {
39
+ PATH: string;
40
+ }) => string | null, program?: string): string;
41
+ /**
42
+ * The environment a control spawn receives: everything the platform sets for
43
+ * the run (credentials, session identity, declared project values) minus any
44
+ * variable that could load code into the CLI process or redirect which
45
+ * executable runs, with the worker's own PATH restored.
46
+ */
47
+ export declare function controlCommandEnvironment(environment: NodeJS.ProcessEnv, trustedPath: string | undefined): Record<string, string>;
48
+ /**
49
+ * Classify an agent shell command. Only a plain `r5dctl` coordination
50
+ * invocation becomes a control command; the returned argv is spawned without a
51
+ * shell. Everything else keeps its ordinary shell semantics and lane.
52
+ */
53
+ export declare function classifyControlCommand(command: string): ControlCommandClassification;
@@ -137,6 +137,11 @@ export type WorkerSessionTarget = {
137
137
  rootProfile: "visible_projects" | "canonical_sync";
138
138
  };
139
139
  type WorkerClientMessage = WorkerRecoveryClientMessage | {
140
+ type: "capacity_report";
141
+ capacity: import("./command-launcher").CommandLaunchCapacityReport & {
142
+ reportedAt: string;
143
+ };
144
+ } | {
140
145
  type: "hello";
141
146
  resumableProtocol: typeof WORKER_RESUMABLE_PROTOCOL;
142
147
  runtimeId: string;
@@ -172,6 +177,7 @@ type WorkerClientMessage = WorkerRecoveryClientMessage | {
172
177
  startedAt: string;
173
178
  interactive?: boolean;
174
179
  workspaceEffect?: "none";
180
+ commandClass?: "control";
175
181
  }>;
176
182
  } | {
177
183
  type: "pty_opened";
@@ -397,6 +403,7 @@ type WorkerServerMessage = WorkerRecoveryServerMessage | {
397
403
  timeoutMs?: number;
398
404
  interactive?: boolean;
399
405
  workspaceEffect?: "none";
406
+ commandClass?: "control";
400
407
  } | {
401
408
  type: "exec_stdin";
402
409
  requestId: string;
@@ -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;