@ricsam/r5d-worker 0.0.80 → 0.0.82

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
@@ -99,6 +99,10 @@ const DEFAULT_READ_MAX_BYTES = 5e4;
99
99
  const MAX_LINE_LENGTH = 2e3;
100
100
  const WORKSPACE_GIT_QUIET_MS = 5e3;
101
101
  const WORKSPACE_GIT_PERIODIC_MS = 6e4;
102
+ const PTY_INPUT_BUSY_GRACE_MS = 3e3;
103
+ const PTY_FOREGROUND_POLL_MS = 1e3;
104
+ const PTY_FOREGROUND_IDLE_ENABLED = process.env.R5D_PTY_FOREGROUND_IDLE !== "0";
105
+ const PTY_TMP_PATH_PREFIX = "r5d-worker-tmp://";
102
106
  const activeProcesses = /* @__PURE__ */ new Map();
103
107
  const credentialBearingProcessGroups = /* @__PURE__ */ new Map();
104
108
  const credentialBearingProcessGroupTargets = /* @__PURE__ */ new Map();
@@ -156,6 +160,106 @@ function assertWorkerChildAdmission(input) {
156
160
  throw new StaleWorkerAdmissionError();
157
161
  }
158
162
  }
163
+ function pathIsInsideRoot(rootPath, candidatePath) {
164
+ const relative = path.relative(rootPath, candidatePath);
165
+ return relative === "" || !relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative);
166
+ }
167
+ function resolvePtyEnvFilePath(requestedPath, temporaryRoot = os.tmpdir()) {
168
+ const canonicalTemporaryRoot = fs.realpathSync.native(temporaryRoot);
169
+ if (requestedPath.startsWith(PTY_TMP_PATH_PREFIX)) {
170
+ const filename = requestedPath.slice(PTY_TMP_PATH_PREFIX.length);
171
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(filename)) {
172
+ throw new Error("PTY environment file temporary token must contain one safe filename");
173
+ }
174
+ return path.join(canonicalTemporaryRoot, filename);
175
+ }
176
+ if (!path.isAbsolute(requestedPath)) {
177
+ throw new Error(`PTY environment file path must be absolute or use ${PTY_TMP_PATH_PREFIX}`);
178
+ }
179
+ const resolvedPath = path.resolve(requestedPath);
180
+ const canonicalParent = fs.realpathSync.native(path.dirname(resolvedPath));
181
+ const canonicalPath = path.join(canonicalParent, path.basename(resolvedPath));
182
+ if (!pathIsInsideRoot(canonicalTemporaryRoot, canonicalPath) || canonicalPath === canonicalTemporaryRoot) {
183
+ throw new Error(`PTY environment file path must be inside ${canonicalTemporaryRoot}`);
184
+ }
185
+ return canonicalPath;
186
+ }
187
+ function removePtyEnvFiles(paths) {
188
+ for (const filePath of paths) {
189
+ try {
190
+ fs.rmSync(filePath, { force: true });
191
+ } catch (error) {
192
+ process.stderr.write(
193
+ `[r5d-worker] failed to remove PTY environment file ${filePath}: ${error instanceof Error ? error.message : String(error)}
194
+ `
195
+ );
196
+ }
197
+ }
198
+ }
199
+ function stagePtyEnvFiles(envFiles, temporaryRoot = os.tmpdir()) {
200
+ const staged = { paths: [], resolvedByRequestedPath: /* @__PURE__ */ new Map() };
201
+ try {
202
+ for (const envFile of envFiles ?? []) {
203
+ if (!envFile || typeof envFile.path !== "string" || typeof envFile.content !== "string") {
204
+ throw new Error("PTY environment files require string path and content values");
205
+ }
206
+ if (envFile.mode !== void 0 && envFile.mode !== 384) {
207
+ throw new Error("PTY environment files must use mode 0600");
208
+ }
209
+ const resolvedPath = resolvePtyEnvFilePath(envFile.path, temporaryRoot);
210
+ if (staged.resolvedByRequestedPath.has(envFile.path) || staged.paths.includes(resolvedPath)) {
211
+ throw new Error(`Duplicate PTY environment file path: ${envFile.path}`);
212
+ }
213
+ const descriptor = fs.openSync(
214
+ resolvedPath,
215
+ fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0),
216
+ 384
217
+ );
218
+ staged.paths.push(resolvedPath);
219
+ staged.resolvedByRequestedPath.set(envFile.path, resolvedPath);
220
+ try {
221
+ fs.writeFileSync(descriptor, envFile.content, "utf8");
222
+ fs.fchmodSync(descriptor, 384);
223
+ fs.fsyncSync(descriptor);
224
+ } finally {
225
+ fs.closeSync(descriptor);
226
+ }
227
+ }
228
+ return staged;
229
+ } catch (error) {
230
+ removePtyEnvFiles(staged.paths);
231
+ throw error;
232
+ }
233
+ }
234
+ function resolvePtyEnvFileReferences(env, staged) {
235
+ return Object.fromEntries(Object.entries(env).map(([name, value]) => [name, staged.resolvedByRequestedPath.get(value) ?? value]));
236
+ }
237
+ function workerCommandHasWorkspaceEffect(message) {
238
+ return message.workspaceEffect !== "none";
239
+ }
240
+ function workerPtyIsWorkspaceBusy(pty, now = Date.now(), foregroundIdleEnabled = PTY_FOREGROUND_IDLE_ENABLED) {
241
+ return !foregroundIdleEnabled || pty.foregroundBusy || now - pty.lastInputAt < PTY_INPUT_BUSY_GRACE_MS;
242
+ }
243
+ function parseLinuxPtyForegroundBusy(stat) {
244
+ const commandEnd = stat.lastIndexOf(")");
245
+ if (commandEnd < 0) return null;
246
+ const fields = stat.slice(commandEnd + 1).trim().split(/\s+/);
247
+ const processGroup = Number(fields[2]);
248
+ const foregroundProcessGroup = Number(fields[5]);
249
+ if (!Number.isSafeInteger(processGroup) || processGroup <= 0 || !Number.isSafeInteger(foregroundProcessGroup)) return null;
250
+ return foregroundProcessGroup !== processGroup;
251
+ }
252
+ const workerPtyTestHarness = {
253
+ temporaryPathPrefix: PTY_TMP_PATH_PREFIX,
254
+ inputBusyGraceMs: PTY_INPUT_BUSY_GRACE_MS,
255
+ resolveEnvFilePath: resolvePtyEnvFilePath,
256
+ stageEnvFiles: stagePtyEnvFiles,
257
+ removeEnvFiles: removePtyEnvFiles,
258
+ resolveEnvFileReferences: resolvePtyEnvFileReferences,
259
+ commandHasWorkspaceEffect: workerCommandHasWorkspaceEffect,
260
+ ptyIsWorkspaceBusy: workerPtyIsWorkspaceBusy,
261
+ parseLinuxForegroundBusy: parseLinuxPtyForegroundBusy
262
+ };
159
263
  function defaultConfigPath() {
160
264
  return path.join(os.homedir(), ".config", "r5d", "r5dctl", "config.json");
161
265
  }
@@ -633,7 +737,7 @@ function preparePlanEnvForShell(input) {
633
737
  fs.mkdirSync(plansDir, { recursive: true });
634
738
  return planEnv(input.planRoot, input.target, input.activePlanId);
635
739
  }
636
- async function verifyR5dctlAuth(baseUrl, token) {
740
+ async function verifyR5dctlAuth(baseUrl, token, workerLabel) {
637
741
  const statusUrl = new URL("/api/r5dctl/auth/status", baseUrl);
638
742
  let response;
639
743
  try {
@@ -662,13 +766,16 @@ async function verifyR5dctlAuth(baseUrl, token) {
662
766
  throw new WorkerServerUnavailableError(`Server unavailable at ${baseUrl} (${response.status})${detail}`);
663
767
  }
664
768
  throw new Error(
665
- `Authentication failed against ${baseUrl} (${response.status})${detail}. Run \`r5dctl auth login --base-url ${baseUrl}\` or pass --token/--api-key for this server.`
769
+ `Authentication failed against ${baseUrl} (${response.status})${detail}. Run \`r5dctl auth login --base-url ${baseUrl} --worker-label ${workerLabel}\` or pass that exact label-bound worker token.`
666
770
  );
667
771
  }
668
772
  function resolveCredentials(options, config) {
669
773
  const token = options.token ?? process.env.R5D_WORKER_TOKEN ?? process.env.R5D_TOKEN ?? process.env.R5DCTL_TOKEN ?? config.token ?? options.apiKey ?? process.env.R5D_API_KEY ?? process.env.R5DCTL_API_KEY ?? config.apiKey;
670
774
  if (!token) {
671
- throw new Error("Authentication required. Run `r5dctl auth login` or set R5D_WORKER_TOKEN/R5D_API_KEY.");
775
+ const workerLabel = options.label ?? "<label>";
776
+ throw new Error(
777
+ `Authentication required. Run \`r5dctl auth login --worker-label ${workerLabel}\` or set R5D_WORKER_TOKEN to that label-bound token.`
778
+ );
672
779
  }
673
780
  return {
674
781
  baseUrl: normalizeBaseUrl(
@@ -2334,7 +2441,9 @@ async function executeCommand(input) {
2334
2441
  });
2335
2442
  spawnedProcess = subprocess;
2336
2443
  credentialBearingProcessGroups.set(subprocess.pid, subprocess);
2337
- credentialBearingProcessGroupTargets.set(subprocess.pid, input.resolvedTarget.target);
2444
+ if (workerCommandHasWorkspaceEffect(input.message)) {
2445
+ credentialBearingProcessGroupTargets.set(subprocess.pid, input.resolvedTarget.target);
2446
+ }
2338
2447
  activeProcesses.set(input.message.runId, {
2339
2448
  process: subprocess,
2340
2449
  target: input.resolvedTarget.target,
@@ -2345,7 +2454,8 @@ async function executeCommand(input) {
2345
2454
  argv: input.message.argv,
2346
2455
  command: input.message.argv.join(" "),
2347
2456
  cwd,
2348
- startedAt: (/* @__PURE__ */ new Date()).toISOString()
2457
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
2458
+ ...input.message.workspaceEffect === "none" ? { workspaceEffect: "none" } : {}
2349
2459
  });
2350
2460
  if (input.message.timeoutMs) {
2351
2461
  timeout = setTimeout(() => {
@@ -2431,7 +2541,9 @@ async function executeStreamingCommand(input) {
2431
2541
  });
2432
2542
  spawnedProcess = subprocess;
2433
2543
  credentialBearingProcessGroups.set(subprocess.pid, subprocess);
2434
- credentialBearingProcessGroupTargets.set(subprocess.pid, input.resolvedTarget.target);
2544
+ if (workerCommandHasWorkspaceEffect(input.message)) {
2545
+ credentialBearingProcessGroupTargets.set(subprocess.pid, input.resolvedTarget.target);
2546
+ }
2435
2547
  activeProcesses.set(input.message.runId, {
2436
2548
  process: subprocess,
2437
2549
  target: input.resolvedTarget.target,
@@ -2444,7 +2556,8 @@ async function executeStreamingCommand(input) {
2444
2556
  command: input.message.command,
2445
2557
  cwd,
2446
2558
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
2447
- ...interactive ? { interactive: true, stdin: subprocess.stdin } : {}
2559
+ ...interactive ? { interactive: true, stdin: subprocess.stdin } : {},
2560
+ ...input.message.workspaceEffect === "none" ? { workspaceEffect: "none" } : {}
2448
2561
  });
2449
2562
  started = true;
2450
2563
  sendWorkerMessage(input.ws, {
@@ -2491,7 +2604,8 @@ async function executeStreamingCommand(input) {
2491
2604
  runId: input.message.runId,
2492
2605
  exitCode,
2493
2606
  durationMs: Date.now() - startedAt,
2494
- ...timedOut ? { timedOut: true } : {}
2607
+ ...timedOut ? { timedOut: true } : {},
2608
+ ...input.message.workspaceEffect === "none" ? { workspaceEffect: "none" } : {}
2495
2609
  };
2496
2610
  pendingProcessTerminals.set(input.message.runId, terminal);
2497
2611
  sendWorkerMessage(input.ws, terminal);
@@ -2503,7 +2617,8 @@ async function executeStreamingCommand(input) {
2503
2617
  type: "exec_error",
2504
2618
  runId: input.message.runId,
2505
2619
  error: message,
2506
- durationMs: Date.now() - startedAt
2620
+ durationMs: Date.now() - startedAt,
2621
+ ...input.message.workspaceEffect === "none" ? { workspaceEffect: "none" } : {}
2507
2622
  };
2508
2623
  pendingProcessTerminals.set(input.message.runId, terminal);
2509
2624
  sendWorkerMessage(input.ws, terminal);
@@ -2584,7 +2699,8 @@ function buildActiveProcessReports() {
2584
2699
  command: active.command,
2585
2700
  ...active.cwd ? { cwd: active.cwd } : {},
2586
2701
  startedAt: active.startedAt,
2587
- ...active.interactive ? { interactive: true } : {}
2702
+ ...active.interactive ? { interactive: true } : {},
2703
+ ...active.workspaceEffect === "none" ? { workspaceEffect: "none" } : {}
2588
2704
  }));
2589
2705
  }
2590
2706
  function sendActiveProcessReport(ws) {
@@ -2596,8 +2712,13 @@ function sendActiveProcessReport(ws) {
2596
2712
  const PTY_BRIDGE_SCRIPT = String.raw`
2597
2713
  const readline = require("node:readline");
2598
2714
  const nodePty = require("node-pty");
2715
+ const fs = require("node:fs");
2716
+ const { execFile } = require("node:child_process");
2599
2717
 
2600
2718
  let ptyProcess = null;
2719
+ let foregroundTimer = null;
2720
+ let foregroundPollInFlight = false;
2721
+ let lastForegroundBusy = null;
2601
2722
 
2602
2723
  function send(message, callback) {
2603
2724
  process.stdout.write(JSON.stringify(message) + "\n", callback);
@@ -2607,6 +2728,46 @@ function decode(data) {
2607
2728
  return Buffer.from(data, "base64").toString("utf8");
2608
2729
  }
2609
2730
 
2731
+ function emitForeground(busy) {
2732
+ if (busy === lastForegroundBusy) return;
2733
+ lastForegroundBusy = busy;
2734
+ send({ type: "foreground", busy });
2735
+ }
2736
+
2737
+ function linuxForegroundBusy(pid) {
2738
+ const stat = fs.readFileSync("/proc/" + pid + "/stat", "utf8");
2739
+ const commandEnd = stat.lastIndexOf(")");
2740
+ if (commandEnd < 0) throw new Error("invalid /proc stat");
2741
+ const fields = stat.slice(commandEnd + 1).trim().split(/\s+/);
2742
+ const processGroup = Number(fields[2]);
2743
+ const foregroundProcessGroup = Number(fields[5]);
2744
+ if (!Number.isSafeInteger(processGroup) || processGroup <= 0 || !Number.isSafeInteger(foregroundProcessGroup)) {
2745
+ throw new Error("invalid process group fields");
2746
+ }
2747
+ return foregroundProcessGroup !== processGroup;
2748
+ }
2749
+
2750
+ function pollForeground() {
2751
+ if (!ptyProcess || !Number.isSafeInteger(ptyProcess.pid) || ptyProcess.pid <= 0) return;
2752
+ if (process.platform === "linux") {
2753
+ try {
2754
+ emitForeground(linuxForegroundBusy(ptyProcess.pid));
2755
+ } catch {
2756
+ emitForeground(true);
2757
+ }
2758
+ return;
2759
+ }
2760
+ if (process.platform !== "darwin" || foregroundPollInFlight) return;
2761
+ foregroundPollInFlight = true;
2762
+ const pid = ptyProcess.pid;
2763
+ execFile("/bin/ps", ["-o", "tpgid=", "-p", String(pid)], (error, stdout) => {
2764
+ foregroundPollInFlight = false;
2765
+ if (!ptyProcess || ptyProcess.pid !== pid) return;
2766
+ const foregroundProcessGroup = Number(String(stdout).trim());
2767
+ emitForeground(Boolean(error) || !Number.isSafeInteger(foregroundProcessGroup) || foregroundProcessGroup !== pid);
2768
+ });
2769
+ }
2770
+
2610
2771
  const rl = readline.createInterface({ input: process.stdin });
2611
2772
 
2612
2773
  rl.on("line", (line) => {
@@ -2625,9 +2786,14 @@ rl.on("line", (line) => {
2625
2786
  send({ type: "output", data: Buffer.from(data, "utf8").toString("base64") });
2626
2787
  });
2627
2788
  ptyProcess.onExit((event) => {
2789
+ if (foregroundTimer) clearInterval(foregroundTimer);
2790
+ foregroundTimer = null;
2628
2791
  send({ type: "exit", exitCode: event.exitCode, signal: event.signal }, () => process.exit(0));
2629
2792
  });
2630
- send({ type: "opened" });
2793
+ send({ type: "opened", pid: ptyProcess.pid });
2794
+ pollForeground();
2795
+ foregroundTimer = setInterval(pollForeground, ${PTY_FOREGROUND_POLL_MS});
2796
+ foregroundTimer.unref();
2631
2797
  return;
2632
2798
  }
2633
2799
 
@@ -2720,7 +2886,11 @@ function createNodePtyBridge(options) {
2720
2886
  };
2721
2887
  const handleEvent = (event) => {
2722
2888
  if (event.type === "opened") {
2723
- options.onOpened();
2889
+ options.onOpened(event.pid);
2890
+ return;
2891
+ }
2892
+ if (event.type === "foreground") {
2893
+ options.onForeground(event.busy);
2724
2894
  return;
2725
2895
  }
2726
2896
  if (event.type === "output") {
@@ -2801,6 +2971,9 @@ function createNodePtyBridge(options) {
2801
2971
  }
2802
2972
  };
2803
2973
  }
2974
+ const workerPtyBridgeTestHarness = {
2975
+ create: createNodePtyBridge
2976
+ };
2804
2977
  function resolveHostShell(command, platform = process.platform) {
2805
2978
  if (platform === "win32") {
2806
2979
  const file2 = process.env.COMSPEC || "powershell.exe";
@@ -2836,64 +3009,90 @@ async function openPty(input) {
2836
3009
  }
2837
3010
  });
2838
3011
  input.assertAdmission();
2839
- const ptyProcess = createNodePtyBridge({
2840
- file: shell.file,
2841
- args: shell.args,
2842
- ptyOptions: {
2843
- name: "xterm-256color",
2844
- cols: Math.max(1, Math.min(Math.floor(input.message.cols || 80), 500)),
2845
- rows: Math.max(1, Math.min(Math.floor(input.message.rows || 24), 500)),
2846
- cwd: input.resolvedTarget.rootPath,
2847
- env: workerChildProcessEnvironment([githubProcessEnv(), input.message.env ?? {}, planProcessEnv, targetProcessEnv, shell.env ?? {}])
2848
- },
2849
- onOpened: () => {
2850
- sendWorkerMessage(input.ws, {
2851
- type: "pty_opened",
2852
- requestId: input.message.requestId,
2853
- ptyId: input.message.ptyId
2854
- });
2855
- },
2856
- onOutput: (data) => {
2857
- outputCoalescer.push(data);
2858
- },
2859
- onExit: (event) => {
2860
- outputCoalescer.flush();
2861
- input.releaseWorkspaceMutation?.();
2862
- activePtys.delete(input.message.ptyId);
2863
- input.onTerminal?.();
2864
- sendWorkerMessage(input.ws, {
2865
- type: "pty_exit",
2866
- ptyId: input.message.ptyId,
2867
- exitCode: event.exitCode,
2868
- signal: event.signal
2869
- });
2870
- },
2871
- onError: (error) => {
2872
- outputCoalescer.flush();
2873
- input.releaseWorkspaceMutation?.();
2874
- activePtys.delete(input.message.ptyId);
2875
- input.onTerminal?.();
2876
- sendWorkerMessage(input.ws, {
2877
- type: "pty_error",
2878
- requestId: input.message.requestId,
2879
- ptyId: input.message.ptyId,
2880
- error: error.message
2881
- });
2882
- },
2883
- onTerminationError: (error) => {
2884
- sendWorkerMessage(input.ws, {
2885
- type: "pty_error",
2886
- requestId: input.message.requestId,
2887
- ptyId: input.message.ptyId,
2888
- error: error.message
2889
- });
2890
- }
2891
- });
2892
- activePtys.set(input.message.ptyId, {
2893
- ...ptyProcess,
2894
- target: input.resolvedTarget.target,
2895
- ...input.releaseWorkspaceMutation ? { releaseWorkspaceMutation: input.releaseWorkspaceMutation } : {}
2896
- });
3012
+ const stagedEnvFiles = stagePtyEnvFiles(input.message.envFiles);
3013
+ let envFilesRemoved = false;
3014
+ const cleanupEnvFiles = () => {
3015
+ if (envFilesRemoved) return;
3016
+ envFilesRemoved = true;
3017
+ removePtyEnvFiles(stagedEnvFiles.paths);
3018
+ };
3019
+ try {
3020
+ const ptyProcess = createNodePtyBridge({
3021
+ file: shell.file,
3022
+ args: shell.args,
3023
+ ptyOptions: {
3024
+ name: "xterm-256color",
3025
+ cols: Math.max(1, Math.min(Math.floor(input.message.cols || 80), 500)),
3026
+ rows: Math.max(1, Math.min(Math.floor(input.message.rows || 24), 500)),
3027
+ cwd: input.resolvedTarget.rootPath,
3028
+ env: workerChildProcessEnvironment([
3029
+ githubProcessEnv(),
3030
+ resolvePtyEnvFileReferences(input.message.env ?? {}, stagedEnvFiles),
3031
+ planProcessEnv,
3032
+ targetProcessEnv,
3033
+ shell.env ?? {}
3034
+ ])
3035
+ },
3036
+ onOpened: () => {
3037
+ sendWorkerMessage(input.ws, {
3038
+ type: "pty_opened",
3039
+ requestId: input.message.requestId,
3040
+ ptyId: input.message.ptyId
3041
+ });
3042
+ },
3043
+ onForeground: (busy) => {
3044
+ const activePty = activePtys.get(input.message.ptyId);
3045
+ if (activePty && (busy || input.message.command === void 0)) activePty.foregroundBusy = busy;
3046
+ },
3047
+ onOutput: (data) => {
3048
+ outputCoalescer.push(data);
3049
+ },
3050
+ onExit: (event) => {
3051
+ outputCoalescer.flush();
3052
+ input.releaseWorkspaceMutation?.();
3053
+ activePtys.delete(input.message.ptyId);
3054
+ cleanupEnvFiles();
3055
+ input.onTerminal?.();
3056
+ sendWorkerMessage(input.ws, {
3057
+ type: "pty_exit",
3058
+ ptyId: input.message.ptyId,
3059
+ exitCode: event.exitCode,
3060
+ signal: event.signal
3061
+ });
3062
+ },
3063
+ onError: (error) => {
3064
+ outputCoalescer.flush();
3065
+ input.releaseWorkspaceMutation?.();
3066
+ activePtys.delete(input.message.ptyId);
3067
+ cleanupEnvFiles();
3068
+ input.onTerminal?.();
3069
+ sendWorkerMessage(input.ws, {
3070
+ type: "pty_error",
3071
+ requestId: input.message.requestId,
3072
+ ptyId: input.message.ptyId,
3073
+ error: error.message
3074
+ });
3075
+ },
3076
+ onTerminationError: (error) => {
3077
+ sendWorkerMessage(input.ws, {
3078
+ type: "pty_error",
3079
+ requestId: input.message.requestId,
3080
+ ptyId: input.message.ptyId,
3081
+ error: error.message
3082
+ });
3083
+ }
3084
+ });
3085
+ activePtys.set(input.message.ptyId, {
3086
+ ...ptyProcess,
3087
+ target: input.resolvedTarget.target,
3088
+ foregroundBusy: true,
3089
+ lastInputAt: 0,
3090
+ ...input.releaseWorkspaceMutation ? { releaseWorkspaceMutation: input.releaseWorkspaceMutation } : {}
3091
+ });
3092
+ } catch (error) {
3093
+ cleanupEnvFiles();
3094
+ throw error;
3095
+ }
2897
3096
  }
2898
3097
  function writePty(ws, message) {
2899
3098
  const ptyProcess = activePtys.get(message.ptyId);
@@ -2905,6 +3104,7 @@ function writePty(ws, message) {
2905
3104
  });
2906
3105
  return;
2907
3106
  }
3107
+ ptyProcess.lastInputAt = Date.now();
2908
3108
  ptyProcess.write(message.data);
2909
3109
  }
2910
3110
  function resizePty(message) {
@@ -3025,7 +3225,7 @@ async function startWorker(options) {
3025
3225
  process.stdout.write(`[r5d-worker] server: ${baseUrl}
3026
3226
  `);
3027
3227
  runGit(["--version"]);
3028
- await verifyR5dctlAuth(baseUrl, token);
3228
+ await verifyR5dctlAuth(baseUrl, token, label);
3029
3229
  const initializedWorkspaceState = await workspaceSyncSingleFlight.runExclusive(() => {
3030
3230
  if (!startupProjectSnapshotRecoveryCompleted) {
3031
3231
  const snapshotRecovery = recoverStaleProjectWorktreeSnapshots({
@@ -3342,13 +3542,24 @@ async function startWorker(options) {
3342
3542
  });
3343
3543
  };
3344
3544
  const activeWorkspaceMutationTargets = () => [
3345
- ...Array.from(activeProcesses.values(), ({ target }) => target),
3545
+ ...Array.from(activeProcesses.values()).filter(workerCommandHasWorkspaceEffect).map(({ target }) => target),
3346
3546
  ...credentialBearingProcessGroupTargets.values(),
3347
3547
  ...workspaceSyncPriorityProcessTargets.values(),
3348
3548
  ...workspaceSyncPriorityOperationTargets.values(),
3349
- ...Array.from(activePtys.values(), ({ target }) => target),
3549
+ ...Array.from(activePtys.values()).filter((pty) => workerPtyIsWorkspaceBusy(pty)).map(({ target }) => target),
3350
3550
  ...workspaceSyncPriorityPtyTargets.values()
3351
3551
  ];
3552
+ let visibleWorkspaceMutationEpoch = 0;
3553
+ const projectWorkspaceMutationEpochs = /* @__PURE__ */ new Map();
3554
+ const recordVisibleWorkspaceMutation = (target) => {
3555
+ if (target.type === "workspace") {
3556
+ if (target.rootProfile === "visible_projects") visibleWorkspaceMutationEpoch += 1;
3557
+ return;
3558
+ }
3559
+ const key = projectBranchKey(target.projectId, target.branchName);
3560
+ projectWorkspaceMutationEpochs.set(key, (projectWorkspaceMutationEpochs.get(key) ?? 0) + 1);
3561
+ };
3562
+ const projectWorkspaceMutationToken = (projectId, branchName) => `${visibleWorkspaceMutationEpoch}:${projectWorkspaceMutationEpochs.get(projectBranchKey(projectId, branchName)) ?? 0}`;
3352
3563
  const canonicalWorkspaceMutationIsActive = (targets) => targets.some((target) => target.type === "workspace" && target.rootProfile === "canonical_sync");
3353
3564
  const projectBranchMountActivityBusy = (projectId, branchName, branchPath) => {
3354
3565
  const activeTargets = activeWorkspaceMutationTargets();
@@ -3385,6 +3596,7 @@ async function startWorker(options) {
3385
3596
  preserveLocalOnInitialOuterAbsence: creatorLocalIncarnation,
3386
3597
  preserveLocalOnHydrationBasisChange: preservesCheckoutPathMove,
3387
3598
  busy: () => projectBranchMountBusy(project.projectId, branch.branchName, branchPath),
3599
+ mutationToken: () => projectWorkspaceMutationToken(project.projectId, branch.branchName),
3388
3600
  busyForRecovery: () => projectBranchMountActivityBusy(project.projectId, branch.branchName, branchPath)
3389
3601
  });
3390
3602
  mounts.push({
@@ -3398,6 +3610,7 @@ async function startWorker(options) {
3398
3610
  preserveLocalOnInitialOuterAbsence: creatorLocalIncarnation,
3399
3611
  preserveLocalOnHydrationBasisChange: preservesCheckoutPathMove,
3400
3612
  busy: () => projectBranchMountBusy(project.projectId, branch.branchName, branchPath),
3613
+ mutationToken: () => projectWorkspaceMutationToken(project.projectId, branch.branchName),
3401
3614
  busyForRecovery: () => projectBranchMountActivityBusy(project.projectId, branch.branchName, branchPath)
3402
3615
  });
3403
3616
  }
@@ -3418,6 +3631,7 @@ async function startWorker(options) {
3418
3631
  const activeTargets = activeWorkspaceMutationTargets();
3419
3632
  return canonicalWorkspaceMutationIsActive(activeTargets) || hasActiveVisibleProjectsWorkspaceTarget(activeTargets);
3420
3633
  },
3634
+ mutationToken: () => String(visibleWorkspaceMutationEpoch),
3421
3635
  busyForRecovery: () => {
3422
3636
  const activeTargets = activeWorkspaceMutationTargets();
3423
3637
  return canonicalWorkspaceMutationIsActive(activeTargets) || hasActiveVisibleProjectsWorkspaceTarget(activeTargets);
@@ -3775,6 +3989,7 @@ async function startWorker(options) {
3775
3989
  localChangesDiscarded: false,
3776
3990
  ...result.conflictPaths ? { conflictPaths: result.conflictPaths } : {},
3777
3991
  ...result.conflictSnapshotRefs ? { conflictSnapshotRefs: result.conflictSnapshotRefs } : {},
3992
+ ...result.conflictKind ? { conflictKind: result.conflictKind } : {},
3778
3993
  ...result.error ? { error: result.error } : {}
3779
3994
  };
3780
3995
  };
@@ -3841,10 +4056,7 @@ async function startWorker(options) {
3841
4056
  };
3842
4057
  }
3843
4058
  const outerRemediation = input.trigger.type === "remediation" || input.trigger.type === "remediation_confirm";
3844
- const ordinaryCycleHasBusyLiveMount = mounts.some(
3845
- (mount) => !(mount.deleteWhenSourceMissing && !fs.existsSync(mount.sourcePath)) && mount.busy?.()
3846
- );
3847
- const observedProjectHeads = outerRemediation || ordinaryCycleHasBusyLiveMount ? [] : observeProjectHeadsBeforeOuterWorkspace({ allowNonFastForward: false, failClosed: false });
4059
+ const observedProjectHeads = outerRemediation ? [] : observeProjectHeadsBeforeOuterWorkspace({ allowNonFastForward: false, failClosed: false });
3848
4060
  const inboundMoveMountIds = new Set(
3849
4061
  observedProjectHeads.flatMap(
3850
4062
  ({ projectId, observations }) => observations.filter(({ shouldMove }) => shouldMove).map(({ branchName }) => projectMountId(projectId, branchName))
@@ -4000,7 +4212,8 @@ async function startWorker(options) {
4000
4212
  );
4001
4213
  workspaceAutomaticTimer.unref();
4002
4214
  };
4003
- const markWorkspaceDirty = (trigger, force = false) => {
4215
+ const markWorkspaceDirty = (trigger, force = false, target) => {
4216
+ if (target) recordVisibleWorkspaceMutation(target);
4004
4217
  scheduleAutomaticWorkspaceSync(trigger, force ? 0 : WORKSPACE_GIT_QUIET_MS);
4005
4218
  };
4006
4219
  const targetMayMutateVisibleWorkspace = (target) => target.type === "project" || target.rootProfile === "visible_projects";
@@ -4261,7 +4474,7 @@ async function startWorker(options) {
4261
4474
  sourcePath: recoverySourceOverrides.get(mount.id) ?? mount.sourcePath,
4262
4475
  busy: mount.busyForRecovery ?? mount.busy
4263
4476
  }));
4264
- recoverWorkspaceGitHydration(workspaceShadowRoot, recoveryMounts);
4477
+ recoverWorkspaceGitHydration(workspaceShadowRoot, recoveryMounts, { preserveStaleBases: true });
4265
4478
  for (const project of message.projects) {
4266
4479
  for (const { branchName } of project.preserveOnlyBranches) {
4267
4480
  if (stillPendingCreatedBranches.has(pendingCreatedBranchKey(project.projectId, branchName))) {
@@ -4437,7 +4650,8 @@ async function startWorker(options) {
4437
4650
  capabilities: {
4438
4651
  updateClis: true,
4439
4652
  browserPortForwarding: true,
4440
- execStdinV1: true
4653
+ execStdinV1: true,
4654
+ ptyEnvFilesV1: true
4441
4655
  },
4442
4656
  projectRoot: projectsRoot,
4443
4657
  artifactRoot,
@@ -4812,6 +5026,9 @@ async function startWorker(options) {
4812
5026
  });
4813
5027
  return;
4814
5028
  }
5029
+ if (active.workspaceEffect !== "none" && targetMayMutateVisibleWorkspace(active.target)) {
5030
+ recordVisibleWorkspaceMutation(active.target);
5031
+ }
4815
5032
  try {
4816
5033
  let bytesWritten = 0;
4817
5034
  if (message.data !== void 0 && message.data.length > 0) {
@@ -4822,8 +5039,8 @@ async function startWorker(options) {
4822
5039
  await active.stdin.end();
4823
5040
  active.stdin = void 0;
4824
5041
  }
4825
- if (targetMayMutateVisibleWorkspace(active.target)) {
4826
- markWorkspaceDirty({ type: "shell_inline", detail: `exec stdin ${message.runId}` });
5042
+ if (active.workspaceEffect !== "none" && targetMayMutateVisibleWorkspace(active.target)) {
5043
+ markWorkspaceDirty({ type: "shell_inline", detail: `exec stdin ${message.runId}` }, false, active.target);
4827
5044
  }
4828
5045
  sendAck({
4829
5046
  result: {
@@ -4850,6 +5067,7 @@ async function startWorker(options) {
4850
5067
  }
4851
5068
  await reserveWorkspaceCommandAfterCurrentSync(message.target, workspaceSyncSingleFlight, () => {
4852
5069
  workspaceSyncPriorityPtyTargets.set(message.ptyId, message.target);
5070
+ if (targetMayMutateVisibleWorkspace(message.target)) recordVisibleWorkspaceMutation(message.target);
4853
5071
  });
4854
5072
  let releaseWorkspaceMutation;
4855
5073
  let mutationLeaseTransferred = false;
@@ -4867,7 +5085,7 @@ async function startWorker(options) {
4867
5085
  ...releaseWorkspaceMutation ? { releaseWorkspaceMutation } : {},
4868
5086
  ...targetMayMutateVisibleWorkspace(message.target) ? {
4869
5087
  onTerminal: () => {
4870
- markWorkspaceDirty({ type: "process_terminal", detail: `pty ${message.ptyId} completed` }, true);
5088
+ markWorkspaceDirty({ type: "process_terminal", detail: `pty ${message.ptyId} completed` }, true, message.target);
4871
5089
  }
4872
5090
  } : {}
4873
5091
  });
@@ -4888,7 +5106,7 @@ async function startWorker(options) {
4888
5106
  if (message.type === "pty_input") {
4889
5107
  const activePty = activePtys.get(message.ptyId);
4890
5108
  if (activePty && targetMayMutateVisibleWorkspace(activePty.target)) {
4891
- markWorkspaceDirty({ type: "shell_inline", detail: `pty ${message.ptyId}` });
5109
+ markWorkspaceDirty({ type: "shell_inline", detail: `pty ${message.ptyId}` }, false, activePty.target);
4892
5110
  }
4893
5111
  writePty(ws, message);
4894
5112
  return;
@@ -4901,7 +5119,7 @@ async function startWorker(options) {
4901
5119
  const activePty = activePtys.get(message.ptyId);
4902
5120
  closePty(message);
4903
5121
  if (activePty && targetMayMutateVisibleWorkspace(activePty.target)) {
4904
- markWorkspaceDirty({ type: "process_terminal", detail: `pty ${message.ptyId} closed` }, true);
5122
+ markWorkspaceDirty({ type: "process_terminal", detail: `pty ${message.ptyId} closed` }, true, activePty.target);
4905
5123
  }
4906
5124
  return;
4907
5125
  }
@@ -4918,11 +5136,15 @@ async function startWorker(options) {
4918
5136
  }
4919
5137
  let result;
4920
5138
  let targetReserved = false;
5139
+ const hasWorkspaceEffect = workerCommandHasWorkspaceEffect(message);
4921
5140
  try {
4922
- await reserveWorkspaceCommandAfterCurrentSync(message.target, workspaceSyncSingleFlight, () => {
4923
- workspaceSyncPriorityProcessTargets.set(message.runId, message.target);
4924
- targetReserved = true;
4925
- });
5141
+ if (hasWorkspaceEffect) {
5142
+ await reserveWorkspaceCommandAfterCurrentSync(message.target, workspaceSyncSingleFlight, () => {
5143
+ workspaceSyncPriorityProcessTargets.set(message.runId, message.target);
5144
+ targetReserved = true;
5145
+ if (targetMayMutateVisibleWorkspace(message.target)) recordVisibleWorkspaceMutation(message.target);
5146
+ });
5147
+ }
4926
5148
  const runCommand = async () => {
4927
5149
  const resolvedTarget = resolveMessageTarget(message.target);
4928
5150
  process.stdout.write(`[r5d-worker] exec ${message.runId}: ${message.argv.join(" ")}
@@ -4952,10 +5174,11 @@ async function startWorker(options) {
4952
5174
  if (targetReserved) workspaceSyncPriorityProcessTargets.delete(message.runId);
4953
5175
  }
4954
5176
  ws.send(JSON.stringify(result));
4955
- if (targetMayMutateVisibleWorkspace(message.target)) {
5177
+ if (hasWorkspaceEffect && targetMayMutateVisibleWorkspace(message.target)) {
4956
5178
  markWorkspaceDirty(
4957
5179
  { type: "shell_inline", sessionId: message.sessionId, processRunId: message.runId, detail: "foreground command completed" },
4958
- true
5180
+ true,
5181
+ message.target
4959
5182
  );
4960
5183
  }
4961
5184
  return;
@@ -4975,6 +5198,7 @@ async function startWorker(options) {
4975
5198
  requestId: message.requestId,
4976
5199
  runId: message.runId
4977
5200
  });
5201
+ const hasWorkspaceEffect = workerCommandHasWorkspaceEffect(message);
4978
5202
  const runCommand = async () => {
4979
5203
  try {
4980
5204
  const resolvedTarget = resolveMessageTarget(message.target);
@@ -4991,7 +5215,7 @@ async function startWorker(options) {
4991
5215
  assertAdmission: assertMessageAdmission
4992
5216
  });
4993
5217
  } finally {
4994
- if (targetMayMutateVisibleWorkspace(message.target)) {
5218
+ if (hasWorkspaceEffect && targetMayMutateVisibleWorkspace(message.target)) {
4995
5219
  markWorkspaceDirty(
4996
5220
  {
4997
5221
  type: "process_terminal",
@@ -4999,7 +5223,8 @@ async function startWorker(options) {
4999
5223
  processRunId: message.runId,
5000
5224
  detail: "process completed"
5001
5225
  },
5002
- true
5226
+ true,
5227
+ message.target
5003
5228
  );
5004
5229
  }
5005
5230
  }
@@ -5007,10 +5232,13 @@ async function startWorker(options) {
5007
5232
  const execution = (async () => {
5008
5233
  let targetReserved = false;
5009
5234
  try {
5010
- await reserveWorkspaceCommandAfterCurrentSync(message.target, workspaceSyncSingleFlight, () => {
5011
- workspaceSyncPriorityProcessTargets.set(message.runId, message.target);
5012
- targetReserved = true;
5013
- });
5235
+ if (hasWorkspaceEffect) {
5236
+ await reserveWorkspaceCommandAfterCurrentSync(message.target, workspaceSyncSingleFlight, () => {
5237
+ workspaceSyncPriorityProcessTargets.set(message.runId, message.target);
5238
+ targetReserved = true;
5239
+ if (targetMayMutateVisibleWorkspace(message.target)) recordVisibleWorkspaceMutation(message.target);
5240
+ });
5241
+ }
5014
5242
  await runWorkspaceCommand(message.target, workspaceSyncSingleFlight, runCommand);
5015
5243
  } finally {
5016
5244
  if (targetReserved) workspaceSyncPriorityProcessTargets.delete(message.runId);
@@ -5033,6 +5261,7 @@ async function startWorker(options) {
5033
5261
  if (reservesVisibleWorkspace) {
5034
5262
  await reserveWorkspaceCommandAfterCurrentSync(message.target, workspaceSyncSingleFlight, () => {
5035
5263
  workspaceSyncPriorityOperationTargets.set(message.requestId, message.target);
5264
+ if (mutatesVisibleWorkspace) recordVisibleWorkspaceMutation(message.target);
5036
5265
  });
5037
5266
  }
5038
5267
  let dirtyTrigger;
@@ -5062,7 +5291,7 @@ async function startWorker(options) {
5062
5291
  toolCallId: message.requestId,
5063
5292
  ...message.target.type === "project" ? { projectId: message.target.projectId, branchName: message.target.branchName } : {}
5064
5293
  };
5065
- markWorkspaceDirty(dirtyTrigger);
5294
+ markWorkspaceDirty(dirtyTrigger, false, message.target);
5066
5295
  }
5067
5296
  } catch (error) {
5068
5297
  ws.send(
@@ -5222,5 +5451,7 @@ export {
5222
5451
  syncSessionArtifacts,
5223
5452
  workerChildProcessEnvironment,
5224
5453
  workerGitSecurityTestHarness,
5454
+ workerPtyBridgeTestHarness,
5455
+ workerPtyTestHarness,
5225
5456
  writeWorkerTextFile
5226
5457
  };