@ricsam/r5d-worker 0.0.81 → 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
  }
@@ -2337,7 +2441,9 @@ async function executeCommand(input) {
2337
2441
  });
2338
2442
  spawnedProcess = subprocess;
2339
2443
  credentialBearingProcessGroups.set(subprocess.pid, subprocess);
2340
- credentialBearingProcessGroupTargets.set(subprocess.pid, input.resolvedTarget.target);
2444
+ if (workerCommandHasWorkspaceEffect(input.message)) {
2445
+ credentialBearingProcessGroupTargets.set(subprocess.pid, input.resolvedTarget.target);
2446
+ }
2341
2447
  activeProcesses.set(input.message.runId, {
2342
2448
  process: subprocess,
2343
2449
  target: input.resolvedTarget.target,
@@ -2348,7 +2454,8 @@ async function executeCommand(input) {
2348
2454
  argv: input.message.argv,
2349
2455
  command: input.message.argv.join(" "),
2350
2456
  cwd,
2351
- startedAt: (/* @__PURE__ */ new Date()).toISOString()
2457
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
2458
+ ...input.message.workspaceEffect === "none" ? { workspaceEffect: "none" } : {}
2352
2459
  });
2353
2460
  if (input.message.timeoutMs) {
2354
2461
  timeout = setTimeout(() => {
@@ -2434,7 +2541,9 @@ async function executeStreamingCommand(input) {
2434
2541
  });
2435
2542
  spawnedProcess = subprocess;
2436
2543
  credentialBearingProcessGroups.set(subprocess.pid, subprocess);
2437
- credentialBearingProcessGroupTargets.set(subprocess.pid, input.resolvedTarget.target);
2544
+ if (workerCommandHasWorkspaceEffect(input.message)) {
2545
+ credentialBearingProcessGroupTargets.set(subprocess.pid, input.resolvedTarget.target);
2546
+ }
2438
2547
  activeProcesses.set(input.message.runId, {
2439
2548
  process: subprocess,
2440
2549
  target: input.resolvedTarget.target,
@@ -2447,7 +2556,8 @@ async function executeStreamingCommand(input) {
2447
2556
  command: input.message.command,
2448
2557
  cwd,
2449
2558
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
2450
- ...interactive ? { interactive: true, stdin: subprocess.stdin } : {}
2559
+ ...interactive ? { interactive: true, stdin: subprocess.stdin } : {},
2560
+ ...input.message.workspaceEffect === "none" ? { workspaceEffect: "none" } : {}
2451
2561
  });
2452
2562
  started = true;
2453
2563
  sendWorkerMessage(input.ws, {
@@ -2494,7 +2604,8 @@ async function executeStreamingCommand(input) {
2494
2604
  runId: input.message.runId,
2495
2605
  exitCode,
2496
2606
  durationMs: Date.now() - startedAt,
2497
- ...timedOut ? { timedOut: true } : {}
2607
+ ...timedOut ? { timedOut: true } : {},
2608
+ ...input.message.workspaceEffect === "none" ? { workspaceEffect: "none" } : {}
2498
2609
  };
2499
2610
  pendingProcessTerminals.set(input.message.runId, terminal);
2500
2611
  sendWorkerMessage(input.ws, terminal);
@@ -2506,7 +2617,8 @@ async function executeStreamingCommand(input) {
2506
2617
  type: "exec_error",
2507
2618
  runId: input.message.runId,
2508
2619
  error: message,
2509
- durationMs: Date.now() - startedAt
2620
+ durationMs: Date.now() - startedAt,
2621
+ ...input.message.workspaceEffect === "none" ? { workspaceEffect: "none" } : {}
2510
2622
  };
2511
2623
  pendingProcessTerminals.set(input.message.runId, terminal);
2512
2624
  sendWorkerMessage(input.ws, terminal);
@@ -2587,7 +2699,8 @@ function buildActiveProcessReports() {
2587
2699
  command: active.command,
2588
2700
  ...active.cwd ? { cwd: active.cwd } : {},
2589
2701
  startedAt: active.startedAt,
2590
- ...active.interactive ? { interactive: true } : {}
2702
+ ...active.interactive ? { interactive: true } : {},
2703
+ ...active.workspaceEffect === "none" ? { workspaceEffect: "none" } : {}
2591
2704
  }));
2592
2705
  }
2593
2706
  function sendActiveProcessReport(ws) {
@@ -2599,8 +2712,13 @@ function sendActiveProcessReport(ws) {
2599
2712
  const PTY_BRIDGE_SCRIPT = String.raw`
2600
2713
  const readline = require("node:readline");
2601
2714
  const nodePty = require("node-pty");
2715
+ const fs = require("node:fs");
2716
+ const { execFile } = require("node:child_process");
2602
2717
 
2603
2718
  let ptyProcess = null;
2719
+ let foregroundTimer = null;
2720
+ let foregroundPollInFlight = false;
2721
+ let lastForegroundBusy = null;
2604
2722
 
2605
2723
  function send(message, callback) {
2606
2724
  process.stdout.write(JSON.stringify(message) + "\n", callback);
@@ -2610,6 +2728,46 @@ function decode(data) {
2610
2728
  return Buffer.from(data, "base64").toString("utf8");
2611
2729
  }
2612
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
+
2613
2771
  const rl = readline.createInterface({ input: process.stdin });
2614
2772
 
2615
2773
  rl.on("line", (line) => {
@@ -2628,9 +2786,14 @@ rl.on("line", (line) => {
2628
2786
  send({ type: "output", data: Buffer.from(data, "utf8").toString("base64") });
2629
2787
  });
2630
2788
  ptyProcess.onExit((event) => {
2789
+ if (foregroundTimer) clearInterval(foregroundTimer);
2790
+ foregroundTimer = null;
2631
2791
  send({ type: "exit", exitCode: event.exitCode, signal: event.signal }, () => process.exit(0));
2632
2792
  });
2633
- send({ type: "opened" });
2793
+ send({ type: "opened", pid: ptyProcess.pid });
2794
+ pollForeground();
2795
+ foregroundTimer = setInterval(pollForeground, ${PTY_FOREGROUND_POLL_MS});
2796
+ foregroundTimer.unref();
2634
2797
  return;
2635
2798
  }
2636
2799
 
@@ -2723,7 +2886,11 @@ function createNodePtyBridge(options) {
2723
2886
  };
2724
2887
  const handleEvent = (event) => {
2725
2888
  if (event.type === "opened") {
2726
- options.onOpened();
2889
+ options.onOpened(event.pid);
2890
+ return;
2891
+ }
2892
+ if (event.type === "foreground") {
2893
+ options.onForeground(event.busy);
2727
2894
  return;
2728
2895
  }
2729
2896
  if (event.type === "output") {
@@ -2804,6 +2971,9 @@ function createNodePtyBridge(options) {
2804
2971
  }
2805
2972
  };
2806
2973
  }
2974
+ const workerPtyBridgeTestHarness = {
2975
+ create: createNodePtyBridge
2976
+ };
2807
2977
  function resolveHostShell(command, platform = process.platform) {
2808
2978
  if (platform === "win32") {
2809
2979
  const file2 = process.env.COMSPEC || "powershell.exe";
@@ -2839,64 +3009,90 @@ async function openPty(input) {
2839
3009
  }
2840
3010
  });
2841
3011
  input.assertAdmission();
2842
- const ptyProcess = createNodePtyBridge({
2843
- file: shell.file,
2844
- args: shell.args,
2845
- ptyOptions: {
2846
- name: "xterm-256color",
2847
- cols: Math.max(1, Math.min(Math.floor(input.message.cols || 80), 500)),
2848
- rows: Math.max(1, Math.min(Math.floor(input.message.rows || 24), 500)),
2849
- cwd: input.resolvedTarget.rootPath,
2850
- env: workerChildProcessEnvironment([githubProcessEnv(), input.message.env ?? {}, planProcessEnv, targetProcessEnv, shell.env ?? {}])
2851
- },
2852
- onOpened: () => {
2853
- sendWorkerMessage(input.ws, {
2854
- type: "pty_opened",
2855
- requestId: input.message.requestId,
2856
- ptyId: input.message.ptyId
2857
- });
2858
- },
2859
- onOutput: (data) => {
2860
- outputCoalescer.push(data);
2861
- },
2862
- onExit: (event) => {
2863
- outputCoalescer.flush();
2864
- input.releaseWorkspaceMutation?.();
2865
- activePtys.delete(input.message.ptyId);
2866
- input.onTerminal?.();
2867
- sendWorkerMessage(input.ws, {
2868
- type: "pty_exit",
2869
- ptyId: input.message.ptyId,
2870
- exitCode: event.exitCode,
2871
- signal: event.signal
2872
- });
2873
- },
2874
- onError: (error) => {
2875
- outputCoalescer.flush();
2876
- input.releaseWorkspaceMutation?.();
2877
- activePtys.delete(input.message.ptyId);
2878
- input.onTerminal?.();
2879
- sendWorkerMessage(input.ws, {
2880
- type: "pty_error",
2881
- requestId: input.message.requestId,
2882
- ptyId: input.message.ptyId,
2883
- error: error.message
2884
- });
2885
- },
2886
- onTerminationError: (error) => {
2887
- sendWorkerMessage(input.ws, {
2888
- type: "pty_error",
2889
- requestId: input.message.requestId,
2890
- ptyId: input.message.ptyId,
2891
- error: error.message
2892
- });
2893
- }
2894
- });
2895
- activePtys.set(input.message.ptyId, {
2896
- ...ptyProcess,
2897
- target: input.resolvedTarget.target,
2898
- ...input.releaseWorkspaceMutation ? { releaseWorkspaceMutation: input.releaseWorkspaceMutation } : {}
2899
- });
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
+ }
2900
3096
  }
2901
3097
  function writePty(ws, message) {
2902
3098
  const ptyProcess = activePtys.get(message.ptyId);
@@ -2908,6 +3104,7 @@ function writePty(ws, message) {
2908
3104
  });
2909
3105
  return;
2910
3106
  }
3107
+ ptyProcess.lastInputAt = Date.now();
2911
3108
  ptyProcess.write(message.data);
2912
3109
  }
2913
3110
  function resizePty(message) {
@@ -3345,13 +3542,24 @@ async function startWorker(options) {
3345
3542
  });
3346
3543
  };
3347
3544
  const activeWorkspaceMutationTargets = () => [
3348
- ...Array.from(activeProcesses.values(), ({ target }) => target),
3545
+ ...Array.from(activeProcesses.values()).filter(workerCommandHasWorkspaceEffect).map(({ target }) => target),
3349
3546
  ...credentialBearingProcessGroupTargets.values(),
3350
3547
  ...workspaceSyncPriorityProcessTargets.values(),
3351
3548
  ...workspaceSyncPriorityOperationTargets.values(),
3352
- ...Array.from(activePtys.values(), ({ target }) => target),
3549
+ ...Array.from(activePtys.values()).filter((pty) => workerPtyIsWorkspaceBusy(pty)).map(({ target }) => target),
3353
3550
  ...workspaceSyncPriorityPtyTargets.values()
3354
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}`;
3355
3563
  const canonicalWorkspaceMutationIsActive = (targets) => targets.some((target) => target.type === "workspace" && target.rootProfile === "canonical_sync");
3356
3564
  const projectBranchMountActivityBusy = (projectId, branchName, branchPath) => {
3357
3565
  const activeTargets = activeWorkspaceMutationTargets();
@@ -3388,6 +3596,7 @@ async function startWorker(options) {
3388
3596
  preserveLocalOnInitialOuterAbsence: creatorLocalIncarnation,
3389
3597
  preserveLocalOnHydrationBasisChange: preservesCheckoutPathMove,
3390
3598
  busy: () => projectBranchMountBusy(project.projectId, branch.branchName, branchPath),
3599
+ mutationToken: () => projectWorkspaceMutationToken(project.projectId, branch.branchName),
3391
3600
  busyForRecovery: () => projectBranchMountActivityBusy(project.projectId, branch.branchName, branchPath)
3392
3601
  });
3393
3602
  mounts.push({
@@ -3401,6 +3610,7 @@ async function startWorker(options) {
3401
3610
  preserveLocalOnInitialOuterAbsence: creatorLocalIncarnation,
3402
3611
  preserveLocalOnHydrationBasisChange: preservesCheckoutPathMove,
3403
3612
  busy: () => projectBranchMountBusy(project.projectId, branch.branchName, branchPath),
3613
+ mutationToken: () => projectWorkspaceMutationToken(project.projectId, branch.branchName),
3404
3614
  busyForRecovery: () => projectBranchMountActivityBusy(project.projectId, branch.branchName, branchPath)
3405
3615
  });
3406
3616
  }
@@ -3421,6 +3631,7 @@ async function startWorker(options) {
3421
3631
  const activeTargets = activeWorkspaceMutationTargets();
3422
3632
  return canonicalWorkspaceMutationIsActive(activeTargets) || hasActiveVisibleProjectsWorkspaceTarget(activeTargets);
3423
3633
  },
3634
+ mutationToken: () => String(visibleWorkspaceMutationEpoch),
3424
3635
  busyForRecovery: () => {
3425
3636
  const activeTargets = activeWorkspaceMutationTargets();
3426
3637
  return canonicalWorkspaceMutationIsActive(activeTargets) || hasActiveVisibleProjectsWorkspaceTarget(activeTargets);
@@ -3778,6 +3989,7 @@ async function startWorker(options) {
3778
3989
  localChangesDiscarded: false,
3779
3990
  ...result.conflictPaths ? { conflictPaths: result.conflictPaths } : {},
3780
3991
  ...result.conflictSnapshotRefs ? { conflictSnapshotRefs: result.conflictSnapshotRefs } : {},
3992
+ ...result.conflictKind ? { conflictKind: result.conflictKind } : {},
3781
3993
  ...result.error ? { error: result.error } : {}
3782
3994
  };
3783
3995
  };
@@ -3844,10 +4056,7 @@ async function startWorker(options) {
3844
4056
  };
3845
4057
  }
3846
4058
  const outerRemediation = input.trigger.type === "remediation" || input.trigger.type === "remediation_confirm";
3847
- const ordinaryCycleHasBusyLiveMount = mounts.some(
3848
- (mount) => !(mount.deleteWhenSourceMissing && !fs.existsSync(mount.sourcePath)) && mount.busy?.()
3849
- );
3850
- const observedProjectHeads = outerRemediation || ordinaryCycleHasBusyLiveMount ? [] : observeProjectHeadsBeforeOuterWorkspace({ allowNonFastForward: false, failClosed: false });
4059
+ const observedProjectHeads = outerRemediation ? [] : observeProjectHeadsBeforeOuterWorkspace({ allowNonFastForward: false, failClosed: false });
3851
4060
  const inboundMoveMountIds = new Set(
3852
4061
  observedProjectHeads.flatMap(
3853
4062
  ({ projectId, observations }) => observations.filter(({ shouldMove }) => shouldMove).map(({ branchName }) => projectMountId(projectId, branchName))
@@ -4003,7 +4212,8 @@ async function startWorker(options) {
4003
4212
  );
4004
4213
  workspaceAutomaticTimer.unref();
4005
4214
  };
4006
- const markWorkspaceDirty = (trigger, force = false) => {
4215
+ const markWorkspaceDirty = (trigger, force = false, target) => {
4216
+ if (target) recordVisibleWorkspaceMutation(target);
4007
4217
  scheduleAutomaticWorkspaceSync(trigger, force ? 0 : WORKSPACE_GIT_QUIET_MS);
4008
4218
  };
4009
4219
  const targetMayMutateVisibleWorkspace = (target) => target.type === "project" || target.rootProfile === "visible_projects";
@@ -4264,7 +4474,7 @@ async function startWorker(options) {
4264
4474
  sourcePath: recoverySourceOverrides.get(mount.id) ?? mount.sourcePath,
4265
4475
  busy: mount.busyForRecovery ?? mount.busy
4266
4476
  }));
4267
- recoverWorkspaceGitHydration(workspaceShadowRoot, recoveryMounts);
4477
+ recoverWorkspaceGitHydration(workspaceShadowRoot, recoveryMounts, { preserveStaleBases: true });
4268
4478
  for (const project of message.projects) {
4269
4479
  for (const { branchName } of project.preserveOnlyBranches) {
4270
4480
  if (stillPendingCreatedBranches.has(pendingCreatedBranchKey(project.projectId, branchName))) {
@@ -4440,7 +4650,8 @@ async function startWorker(options) {
4440
4650
  capabilities: {
4441
4651
  updateClis: true,
4442
4652
  browserPortForwarding: true,
4443
- execStdinV1: true
4653
+ execStdinV1: true,
4654
+ ptyEnvFilesV1: true
4444
4655
  },
4445
4656
  projectRoot: projectsRoot,
4446
4657
  artifactRoot,
@@ -4815,6 +5026,9 @@ async function startWorker(options) {
4815
5026
  });
4816
5027
  return;
4817
5028
  }
5029
+ if (active.workspaceEffect !== "none" && targetMayMutateVisibleWorkspace(active.target)) {
5030
+ recordVisibleWorkspaceMutation(active.target);
5031
+ }
4818
5032
  try {
4819
5033
  let bytesWritten = 0;
4820
5034
  if (message.data !== void 0 && message.data.length > 0) {
@@ -4825,8 +5039,8 @@ async function startWorker(options) {
4825
5039
  await active.stdin.end();
4826
5040
  active.stdin = void 0;
4827
5041
  }
4828
- if (targetMayMutateVisibleWorkspace(active.target)) {
4829
- 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);
4830
5044
  }
4831
5045
  sendAck({
4832
5046
  result: {
@@ -4853,6 +5067,7 @@ async function startWorker(options) {
4853
5067
  }
4854
5068
  await reserveWorkspaceCommandAfterCurrentSync(message.target, workspaceSyncSingleFlight, () => {
4855
5069
  workspaceSyncPriorityPtyTargets.set(message.ptyId, message.target);
5070
+ if (targetMayMutateVisibleWorkspace(message.target)) recordVisibleWorkspaceMutation(message.target);
4856
5071
  });
4857
5072
  let releaseWorkspaceMutation;
4858
5073
  let mutationLeaseTransferred = false;
@@ -4870,7 +5085,7 @@ async function startWorker(options) {
4870
5085
  ...releaseWorkspaceMutation ? { releaseWorkspaceMutation } : {},
4871
5086
  ...targetMayMutateVisibleWorkspace(message.target) ? {
4872
5087
  onTerminal: () => {
4873
- markWorkspaceDirty({ type: "process_terminal", detail: `pty ${message.ptyId} completed` }, true);
5088
+ markWorkspaceDirty({ type: "process_terminal", detail: `pty ${message.ptyId} completed` }, true, message.target);
4874
5089
  }
4875
5090
  } : {}
4876
5091
  });
@@ -4891,7 +5106,7 @@ async function startWorker(options) {
4891
5106
  if (message.type === "pty_input") {
4892
5107
  const activePty = activePtys.get(message.ptyId);
4893
5108
  if (activePty && targetMayMutateVisibleWorkspace(activePty.target)) {
4894
- markWorkspaceDirty({ type: "shell_inline", detail: `pty ${message.ptyId}` });
5109
+ markWorkspaceDirty({ type: "shell_inline", detail: `pty ${message.ptyId}` }, false, activePty.target);
4895
5110
  }
4896
5111
  writePty(ws, message);
4897
5112
  return;
@@ -4904,7 +5119,7 @@ async function startWorker(options) {
4904
5119
  const activePty = activePtys.get(message.ptyId);
4905
5120
  closePty(message);
4906
5121
  if (activePty && targetMayMutateVisibleWorkspace(activePty.target)) {
4907
- markWorkspaceDirty({ type: "process_terminal", detail: `pty ${message.ptyId} closed` }, true);
5122
+ markWorkspaceDirty({ type: "process_terminal", detail: `pty ${message.ptyId} closed` }, true, activePty.target);
4908
5123
  }
4909
5124
  return;
4910
5125
  }
@@ -4921,11 +5136,15 @@ async function startWorker(options) {
4921
5136
  }
4922
5137
  let result;
4923
5138
  let targetReserved = false;
5139
+ const hasWorkspaceEffect = workerCommandHasWorkspaceEffect(message);
4924
5140
  try {
4925
- await reserveWorkspaceCommandAfterCurrentSync(message.target, workspaceSyncSingleFlight, () => {
4926
- workspaceSyncPriorityProcessTargets.set(message.runId, message.target);
4927
- targetReserved = true;
4928
- });
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
+ }
4929
5148
  const runCommand = async () => {
4930
5149
  const resolvedTarget = resolveMessageTarget(message.target);
4931
5150
  process.stdout.write(`[r5d-worker] exec ${message.runId}: ${message.argv.join(" ")}
@@ -4955,10 +5174,11 @@ async function startWorker(options) {
4955
5174
  if (targetReserved) workspaceSyncPriorityProcessTargets.delete(message.runId);
4956
5175
  }
4957
5176
  ws.send(JSON.stringify(result));
4958
- if (targetMayMutateVisibleWorkspace(message.target)) {
5177
+ if (hasWorkspaceEffect && targetMayMutateVisibleWorkspace(message.target)) {
4959
5178
  markWorkspaceDirty(
4960
5179
  { type: "shell_inline", sessionId: message.sessionId, processRunId: message.runId, detail: "foreground command completed" },
4961
- true
5180
+ true,
5181
+ message.target
4962
5182
  );
4963
5183
  }
4964
5184
  return;
@@ -4978,6 +5198,7 @@ async function startWorker(options) {
4978
5198
  requestId: message.requestId,
4979
5199
  runId: message.runId
4980
5200
  });
5201
+ const hasWorkspaceEffect = workerCommandHasWorkspaceEffect(message);
4981
5202
  const runCommand = async () => {
4982
5203
  try {
4983
5204
  const resolvedTarget = resolveMessageTarget(message.target);
@@ -4994,7 +5215,7 @@ async function startWorker(options) {
4994
5215
  assertAdmission: assertMessageAdmission
4995
5216
  });
4996
5217
  } finally {
4997
- if (targetMayMutateVisibleWorkspace(message.target)) {
5218
+ if (hasWorkspaceEffect && targetMayMutateVisibleWorkspace(message.target)) {
4998
5219
  markWorkspaceDirty(
4999
5220
  {
5000
5221
  type: "process_terminal",
@@ -5002,7 +5223,8 @@ async function startWorker(options) {
5002
5223
  processRunId: message.runId,
5003
5224
  detail: "process completed"
5004
5225
  },
5005
- true
5226
+ true,
5227
+ message.target
5006
5228
  );
5007
5229
  }
5008
5230
  }
@@ -5010,10 +5232,13 @@ async function startWorker(options) {
5010
5232
  const execution = (async () => {
5011
5233
  let targetReserved = false;
5012
5234
  try {
5013
- await reserveWorkspaceCommandAfterCurrentSync(message.target, workspaceSyncSingleFlight, () => {
5014
- workspaceSyncPriorityProcessTargets.set(message.runId, message.target);
5015
- targetReserved = true;
5016
- });
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
+ }
5017
5242
  await runWorkspaceCommand(message.target, workspaceSyncSingleFlight, runCommand);
5018
5243
  } finally {
5019
5244
  if (targetReserved) workspaceSyncPriorityProcessTargets.delete(message.runId);
@@ -5036,6 +5261,7 @@ async function startWorker(options) {
5036
5261
  if (reservesVisibleWorkspace) {
5037
5262
  await reserveWorkspaceCommandAfterCurrentSync(message.target, workspaceSyncSingleFlight, () => {
5038
5263
  workspaceSyncPriorityOperationTargets.set(message.requestId, message.target);
5264
+ if (mutatesVisibleWorkspace) recordVisibleWorkspaceMutation(message.target);
5039
5265
  });
5040
5266
  }
5041
5267
  let dirtyTrigger;
@@ -5065,7 +5291,7 @@ async function startWorker(options) {
5065
5291
  toolCallId: message.requestId,
5066
5292
  ...message.target.type === "project" ? { projectId: message.target.projectId, branchName: message.target.branchName } : {}
5067
5293
  };
5068
- markWorkspaceDirty(dirtyTrigger);
5294
+ markWorkspaceDirty(dirtyTrigger, false, message.target);
5069
5295
  }
5070
5296
  } catch (error) {
5071
5297
  ws.send(
@@ -5225,5 +5451,7 @@ export {
5225
5451
  syncSessionArtifacts,
5226
5452
  workerChildProcessEnvironment,
5227
5453
  workerGitSecurityTestHarness,
5454
+ workerPtyBridgeTestHarness,
5455
+ workerPtyTestHarness,
5228
5456
  writeWorkerTextFile
5229
5457
  };