@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/README.md +6 -3
- package/dist/cjs/command-launcher.cjs +76 -2
- package/dist/cjs/control-command-policy.cjs +177 -0
- package/dist/cjs/main.cjs +75 -10
- package/dist/cjs/package.json +1 -1
- package/dist/cjs/project-checkout-garbage.cjs +190 -0
- package/dist/cjs/project-worktrees.cjs +9 -2
- package/dist/cjs/recovery-store.cjs +13 -0
- package/dist/mjs/command-launcher.mjs +75 -2
- package/dist/mjs/control-command-policy.mjs +146 -0
- package/dist/mjs/main.mjs +80 -10
- package/dist/mjs/package.json +1 -1
- package/dist/mjs/project-checkout-garbage.mjs +150 -0
- package/dist/mjs/project-worktrees.mjs +9 -2
- package/dist/mjs/recovery-store.mjs +13 -0
- package/dist/types/command-launcher.d.ts +42 -0
- package/dist/types/control-command-policy.d.ts +53 -0
- package/dist/types/main.d.ts +7 -0
- package/dist/types/project-checkout-garbage.d.ts +69 -0
- package/dist/types/project-worktrees.d.ts +6 -0
- package/dist/types/recovery-store.d.ts +8 -0
- package/package.json +1 -1
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(
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
},
|
|
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
|
-
|
|
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(
|
|
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,
|
package/dist/mjs/package.json
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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;
|
package/dist/types/main.d.ts
CHANGED
|
@@ -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;
|