@ricsam/r5d-worker 0.0.14 → 0.0.16

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
@@ -18,7 +18,7 @@ const LEGACY_BUILD_IT_NOW_REMOTE_NAME = "build-it-now";
18
18
  const activeProcesses = /* @__PURE__ */ new Map();
19
19
  const cancelledProcessRuns = /* @__PURE__ */ new Set();
20
20
  const activePtys = /* @__PURE__ */ new Map();
21
- const branchLocks = /* @__PURE__ */ new Map();
21
+ let currentWorkerSocket = null;
22
22
  let containerRegistryCredential = null;
23
23
  let visibleGitIdentity = null;
24
24
  function defaultConfigPath() {
@@ -33,6 +33,9 @@ function defaultSyncRoot() {
33
33
  function defaultArtifactRoot() {
34
34
  return path.join(os.homedir(), ".r5d", "artifacts");
35
35
  }
36
+ function defaultPlanRoot() {
37
+ return path.join(os.homedir(), ".r5d", "plans");
38
+ }
36
39
  function printHelp() {
37
40
  process.stdout.write(`Usage:
38
41
  r5d-worker start --label <label> [--base-url <url>] [--token <token>] [--api-key <key>]
@@ -47,6 +50,7 @@ Options:
47
50
  --project-root <dir> Override projects checkout root (default ~/.r5d/projects)
48
51
  --sync-root <dir> Override r5d internal sync git root (default ~/.r5d/sync)
49
52
  --artifact-root <dir> Override session artifact root (default from R5D_ARTIFACTS_ROOT or ~/.r5d/artifacts)
53
+ --plan-root <dir> Override synced plan root (default from R5D_PLANS_ROOT or ~/.r5d/plans)
50
54
  -v, --version Show r5d-worker version
51
55
  -h, --help Show this help
52
56
  `);
@@ -168,6 +172,11 @@ function parseArgs(argv) {
168
172
  options.artifactRoot = artifactRoot;
169
173
  continue;
170
174
  }
175
+ const planRoot = readOption("--plan-root");
176
+ if (planRoot !== void 0) {
177
+ options.planRoot = planRoot;
178
+ continue;
179
+ }
171
180
  rest.push(arg);
172
181
  }
173
182
  if (options.version) {
@@ -211,6 +220,7 @@ async function readResponseText(response) {
211
220
  }
212
221
  }
213
222
  const R5D_ARTIFACTS_DIR_REF = "$R5D_ARTIFACTS_DIR";
223
+ const R5D_PLANS_DIR_ENV = "R5D_PLANS_DIR";
214
224
  function validateArtifactSessionId(sessionId) {
215
225
  if (!/^(migration-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(sessionId)) {
216
226
  throw new Error(`Invalid artifact session id: ${sessionId}`);
@@ -219,7 +229,7 @@ function validateArtifactSessionId(sessionId) {
219
229
  function assertInsideRoot(rootPath, candidatePath, label) {
220
230
  const relative = path.relative(rootPath, candidatePath);
221
231
  if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
222
- throw new Error(`${label} escapes the artifact directory`);
232
+ throw new Error(`${label} escapes sync directory`);
223
233
  }
224
234
  }
225
235
  function sessionArtifactDir(artifactRoot, sessionId) {
@@ -234,6 +244,23 @@ function artifactEnv(artifactRoot, sessionId) {
234
244
  R5D_ARTIFACTS_DIR: sessionArtifactDir(artifactRoot, sessionId)
235
245
  };
236
246
  }
247
+ function projectPlanDir(planRoot, projectId, branchName) {
248
+ validateProjectId(projectId);
249
+ validateBranchName(branchName);
250
+ return path.join(planRoot, projectId, branchName);
251
+ }
252
+ function planEnv(planRoot, projectId, branchName, activePlanId) {
253
+ const plansDir = projectPlanDir(planRoot, projectId, branchName);
254
+ const env = {
255
+ [R5D_PLANS_DIR_ENV]: plansDir
256
+ };
257
+ if (activePlanId) {
258
+ validatePlanId(activePlanId);
259
+ env.R5D_ACTIVE_PLAN_ID = activePlanId;
260
+ env.R5D_ACTIVE_PLAN_FILE = path.join(plansDir, `${activePlanId}.plan.md`);
261
+ }
262
+ return env;
263
+ }
237
264
  function isArtifactEnvPath(filePath) {
238
265
  return filePath === R5D_ARTIFACTS_DIR_REF || filePath.startsWith(`${R5D_ARTIFACTS_DIR_REF}/`);
239
266
  }
@@ -246,99 +273,150 @@ function artifactManifestUrl(baseUrl, projectId, branchName, sessionId) {
246
273
  function artifactDownloadUrl(baseUrl, projectId, branchName, sessionId, filename) {
247
274
  return new URL(`${artifactApiBasePath(projectId, branchName, sessionId)}/${encodeURIComponent(filename)}`, baseUrl);
248
275
  }
276
+ function planApiBasePath(projectId, branchName) {
277
+ return `/preview-api/${encodeURIComponent(projectId)}/${encodeURIComponent(branchName)}/plans`;
278
+ }
279
+ function planManifestUrl(baseUrl, projectId, branchName) {
280
+ return new URL(planApiBasePath(projectId, branchName), baseUrl);
281
+ }
282
+ function planDownloadUrl(baseUrl, projectId, branchName, filename) {
283
+ return new URL(`${planApiBasePath(projectId, branchName)}/${encodeURIComponent(filename)}`, baseUrl);
284
+ }
249
285
  function sha256Path(filePath) {
250
286
  return createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
251
287
  }
252
- function parseSessionArtifactManifest(value) {
253
- if (!value || typeof value !== "object" || !Array.isArray(value.artifacts)) {
254
- throw new Error("Artifact manifest response is invalid");
288
+ function parseRemoteFileManifest(value, collectionKey, label) {
289
+ if (!value || typeof value !== "object" || !Array.isArray(value[collectionKey])) {
290
+ throw new Error(`${label} manifest response is invalid`);
255
291
  }
256
- return value.artifacts.map((entry) => {
292
+ return value[collectionKey].map((entry) => {
257
293
  if (!entry || typeof entry !== "object") {
258
- throw new Error("Artifact manifest entry is invalid");
294
+ throw new Error(`${label} manifest entry is invalid`);
259
295
  }
260
- const artifact = entry;
261
- if (typeof artifact.filename !== "string" || artifact.filename.includes("/") || artifact.filename.includes("\\")) {
262
- throw new Error("Artifact manifest entry has an invalid filename");
296
+ const file = entry;
297
+ if (typeof file.filename !== "string" || file.filename.includes("/") || file.filename.includes("\\")) {
298
+ throw new Error(`${label} manifest entry has an invalid filename`);
263
299
  }
264
- if (typeof artifact.size !== "number" || !Number.isFinite(artifact.size) || artifact.size < 0) {
265
- throw new Error(`Artifact manifest entry has an invalid size: ${artifact.filename}`);
300
+ if (typeof file.size !== "number" || !Number.isFinite(file.size) || file.size < 0) {
301
+ throw new Error(`${label} manifest entry has an invalid size: ${file.filename}`);
266
302
  }
267
- if (typeof artifact.sha256 !== "string" || !/^[a-f0-9]{64}$/i.test(artifact.sha256)) {
268
- throw new Error(`Artifact manifest entry has an invalid sha256: ${artifact.filename}`);
303
+ if (typeof file.sha256 !== "string" || !/^[a-f0-9]{64}$/i.test(file.sha256)) {
304
+ throw new Error(`${label} manifest entry has an invalid sha256: ${file.filename}`);
269
305
  }
270
306
  return {
271
- filename: artifact.filename,
272
- size: artifact.size,
273
- sha256: artifact.sha256.toLowerCase()
307
+ filename: file.filename,
308
+ size: file.size,
309
+ sha256: file.sha256.toLowerCase()
274
310
  };
275
311
  });
276
312
  }
277
- async function fetchSessionArtifactManifest(input) {
278
- const response = await fetch(artifactManifestUrl(input.baseUrl, input.projectId, input.branchName, input.sessionId), {
313
+ async function fetchRemoteFileManifest(input) {
314
+ const response = await fetch(input.url, {
279
315
  headers: {
280
316
  Authorization: `Bearer ${input.token}`
281
317
  }
282
318
  });
283
319
  if (!response.ok) {
284
320
  const detail = (await readResponseText(response)).trim();
285
- throw new Error(`Failed to fetch artifact manifest (${response.status})${detail ? `: ${detail}` : ""}`);
321
+ throw new Error(`Failed to fetch ${input.label} manifest (${response.status})${detail ? `: ${detail}` : ""}`);
286
322
  }
287
- return parseSessionArtifactManifest(await response.json());
323
+ return parseRemoteFileManifest(await response.json(), input.collectionKey, input.label);
288
324
  }
289
- async function downloadSessionArtifact(input) {
290
- const targetDir = sessionArtifactDir(input.artifactRoot, input.sessionId);
291
- const targetPath = path.join(targetDir, input.artifact.filename);
292
- assertInsideRoot(targetDir, targetPath, "Artifact path");
293
- const response = await fetch(
294
- artifactDownloadUrl(input.baseUrl, input.projectId, input.branchName, input.sessionId, input.artifact.filename),
295
- {
296
- headers: {
297
- Authorization: `Bearer ${input.token}`
298
- }
325
+ async function downloadRemoteSyncedFile(input) {
326
+ const targetPath = path.join(input.targetDir, input.file.filename);
327
+ assertInsideRoot(input.targetDir, targetPath, input.label);
328
+ const response = await fetch(input.url, {
329
+ headers: {
330
+ Authorization: `Bearer ${input.token}`
299
331
  }
300
- );
332
+ });
301
333
  if (!response.ok) {
302
334
  const detail = (await readResponseText(response)).trim();
303
- throw new Error(`Failed to download artifact ${input.artifact.filename} (${response.status})${detail ? `: ${detail}` : ""}`);
335
+ throw new Error(`Failed to download ${input.label} ${input.file.filename} (${response.status})${detail ? `: ${detail}` : ""}`);
304
336
  }
305
337
  const buffer = Buffer.from(await response.arrayBuffer());
306
338
  const receivedHash = createHash("sha256").update(buffer).digest("hex");
307
- if (receivedHash !== input.artifact.sha256) {
308
- throw new Error(`Downloaded artifact ${input.artifact.filename} failed sha256 verification`);
339
+ if (receivedHash !== input.file.sha256) {
340
+ throw new Error(`Downloaded ${input.label} ${input.file.filename} failed sha256 verification`);
309
341
  }
310
- if (buffer.length !== input.artifact.size) {
311
- throw new Error(`Downloaded artifact ${input.artifact.filename} has unexpected size`);
342
+ if (buffer.length !== input.file.size) {
343
+ throw new Error(`Downloaded ${input.label} ${input.file.filename} has unexpected size`);
312
344
  }
313
345
  const tempPath = `${targetPath}.${process.pid}.${Date.now()}.tmp`;
314
346
  fs.writeFileSync(tempPath, buffer);
315
347
  fs.renameSync(tempPath, targetPath);
316
348
  }
317
- async function syncSessionArtifacts(input) {
318
- const targetDir = sessionArtifactDir(input.artifactRoot, input.sessionId);
319
- fs.mkdirSync(targetDir, { recursive: true });
320
- const artifacts = await fetchSessionArtifactManifest(input);
321
- const manifestNames = new Set(artifacts.map((artifact) => artifact.filename));
322
- for (const artifact of artifacts) {
323
- const localPath = path.join(targetDir, artifact.filename);
324
- assertInsideRoot(targetDir, localPath, "Artifact path");
349
+ async function syncRemoteFileSet(input) {
350
+ fs.mkdirSync(input.targetDir, { recursive: true });
351
+ const manifestNames = new Set(input.files.map((file) => file.filename));
352
+ for (const file of input.files) {
353
+ const localPath = path.join(input.targetDir, file.filename);
354
+ assertInsideRoot(input.targetDir, localPath, input.label);
325
355
  if (fs.existsSync(localPath)) {
326
356
  const stats = fs.statSync(localPath);
327
- if (stats.isFile() && stats.size === artifact.size && sha256Path(localPath) === artifact.sha256) {
357
+ if (stats.isFile() && stats.size === file.size && sha256Path(localPath) === file.sha256) {
328
358
  continue;
329
359
  }
330
360
  fs.rmSync(localPath, { recursive: true, force: true });
331
361
  }
332
- await downloadSessionArtifact({ ...input, artifact });
362
+ await input.download(file);
333
363
  }
334
- for (const localName of fs.readdirSync(targetDir)) {
364
+ for (const localName of fs.readdirSync(input.targetDir)) {
335
365
  if (!manifestNames.has(localName)) {
336
- const localPath = path.join(targetDir, localName);
337
- assertInsideRoot(targetDir, localPath, "Artifact path");
366
+ const localPath = path.join(input.targetDir, localName);
367
+ assertInsideRoot(input.targetDir, localPath, input.label);
338
368
  fs.rmSync(localPath, { recursive: true, force: true });
339
369
  }
340
370
  }
341
- return targetDir;
371
+ return input.targetDir;
372
+ }
373
+ async function fetchSessionArtifactManifest(input) {
374
+ return fetchRemoteFileManifest({
375
+ url: artifactManifestUrl(input.baseUrl, input.projectId, input.branchName, input.sessionId),
376
+ token: input.token,
377
+ collectionKey: "artifacts",
378
+ label: "artifact"
379
+ });
380
+ }
381
+ async function syncSessionArtifacts(input) {
382
+ const targetDir = sessionArtifactDir(input.artifactRoot, input.sessionId);
383
+ const artifacts = await fetchSessionArtifactManifest(input);
384
+ return syncRemoteFileSet({
385
+ targetDir,
386
+ files: artifacts,
387
+ label: "Artifact path",
388
+ download: (artifact) => downloadRemoteSyncedFile({
389
+ url: artifactDownloadUrl(input.baseUrl, input.projectId, input.branchName, input.sessionId, artifact.filename),
390
+ token: input.token,
391
+ targetDir,
392
+ file: artifact,
393
+ label: "artifact"
394
+ })
395
+ });
396
+ }
397
+ async function fetchProjectPlanManifest(input) {
398
+ return fetchRemoteFileManifest({
399
+ url: planManifestUrl(input.baseUrl, input.projectId, input.branchName),
400
+ token: input.token,
401
+ collectionKey: "plans",
402
+ label: "plan"
403
+ });
404
+ }
405
+ async function syncProjectPlans(input) {
406
+ const targetDir = projectPlanDir(input.planRoot, input.projectId, input.branchName);
407
+ const plans = await fetchProjectPlanManifest(input);
408
+ return syncRemoteFileSet({
409
+ targetDir,
410
+ files: plans,
411
+ label: "Plan path",
412
+ download: (plan) => downloadRemoteSyncedFile({
413
+ url: planDownloadUrl(input.baseUrl, input.projectId, input.branchName, plan.filename),
414
+ token: input.token,
415
+ targetDir,
416
+ file: plan,
417
+ label: "plan"
418
+ })
419
+ });
342
420
  }
343
421
  async function resolveArtifactEnvFilePath(input) {
344
422
  if (!isArtifactEnvPath(input.filePath)) {
@@ -397,6 +475,19 @@ async function prepareArtifactEnvForShell(input) {
397
475
  R5D_ARTIFACTS_DIR: artifactsDir
398
476
  };
399
477
  }
478
+ async function preparePlanEnvForShell(input) {
479
+ try {
480
+ await syncProjectPlans(input);
481
+ } catch (error) {
482
+ process.stderr.write(
483
+ `[r5d-worker] plan sync skipped for ${input.projectId}/${input.branchName}: ${error instanceof Error ? error.message : String(error)}
484
+ `
485
+ );
486
+ }
487
+ const plansDir = projectPlanDir(input.planRoot, input.projectId, input.branchName);
488
+ fs.mkdirSync(plansDir, { recursive: true });
489
+ return planEnv(input.planRoot, input.projectId, input.branchName, input.activePlanId);
490
+ }
400
491
  async function verifyR5dctlAuth(baseUrl, token) {
401
492
  const statusUrl = new URL("/api/r5dctl/auth/status", baseUrl);
402
493
  let response;
@@ -441,11 +532,24 @@ function validateLabel(label) {
441
532
  throw new Error("Worker label must start with a letter or number and may contain letters, numbers, dots, underscores, and hyphens");
442
533
  }
443
534
  }
535
+ function validateProjectId(projectId) {
536
+ if (projectId === "local" || projectId === "local-code") {
537
+ return;
538
+ }
539
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(projectId)) {
540
+ throw new Error(`Invalid project id from server: ${projectId}`);
541
+ }
542
+ }
444
543
  function validateBranchName(branchName) {
445
544
  if (branchName !== "main" && !BRANCH_RE.test(branchName)) {
446
545
  throw new Error(`Invalid branch name from server: ${branchName}`);
447
546
  }
448
547
  }
548
+ function validatePlanId(planId) {
549
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(planId)) {
550
+ throw new Error(`Invalid plan id from server: ${planId}`);
551
+ }
552
+ }
449
553
  function gitExtraHeaderConfigKey(extraHeaderUrl) {
450
554
  return `http.${normalizeBaseUrl(extraHeaderUrl)}/.extraHeader`;
451
555
  }
@@ -546,31 +650,6 @@ function resolveProjectFilePath(branchPath, virtualPath) {
546
650
  assertInsideBranch(branchPath, absolutePath);
547
651
  return { absolutePath, virtualPath: repoRelativePath ? `/${repoRelativePath}` : "/", repoRelativePath };
548
652
  }
549
- function branchLockKey(projectId, branchName) {
550
- return `${projectId}:${branchName}`;
551
- }
552
- async function withBranchLock(projectId, branchName, fn) {
553
- const key = branchLockKey(projectId, branchName);
554
- const previous = branchLocks.get(key) ?? Promise.resolve();
555
- let release = () => {
556
- };
557
- const next = new Promise((resolve) => {
558
- release = resolve;
559
- });
560
- const queued = previous.catch(() => {
561
- }).then(() => next);
562
- branchLocks.set(key, queued);
563
- await previous.catch(() => {
564
- });
565
- try {
566
- return await fn();
567
- } finally {
568
- release();
569
- if (branchLocks.get(key) === queued) {
570
- branchLocks.delete(key);
571
- }
572
- }
573
- }
574
653
  function resolveRemoteUrl(baseUrl, remoteUrl) {
575
654
  if (/^https?:\/\//i.test(remoteUrl)) {
576
655
  return remoteUrl;
@@ -1494,6 +1573,14 @@ async function executeCommand(input) {
1494
1573
  sessionId: input.message.sessionId,
1495
1574
  artifactRoot: input.artifactRoot
1496
1575
  });
1576
+ const planProcessEnv = await preparePlanEnvForShell({
1577
+ baseUrl: input.baseUrl,
1578
+ token: input.token,
1579
+ projectId: input.projectId,
1580
+ branchName: input.message.branchName,
1581
+ planRoot: input.planRoot,
1582
+ activePlanId: input.message.env?.R5D_ACTIVE_PLAN_ID
1583
+ });
1497
1584
  const cwd = resolveCommandCwd(workspace.branchPath, input.message.cwd);
1498
1585
  const subprocess = Bun.spawn(input.message.argv, {
1499
1586
  cwd,
@@ -1504,10 +1591,20 @@ async function executeCommand(input) {
1504
1591
  ...containerRegistryEnv(),
1505
1592
  ...input.message.env ?? {},
1506
1593
  ...internalGitProcessEnv(workspace, input.message.env),
1507
- ...artifactProcessEnv
1594
+ ...artifactProcessEnv,
1595
+ ...planProcessEnv
1508
1596
  }
1509
1597
  });
1510
- activeProcesses.set(input.message.runId, { process: subprocess, command: input.message.argv });
1598
+ activeProcesses.set(input.message.runId, {
1599
+ process: subprocess,
1600
+ projectId: input.projectId,
1601
+ sessionId: input.message.sessionId ?? "",
1602
+ branchName: input.message.branchName,
1603
+ argv: input.message.argv,
1604
+ command: input.message.argv.join(" "),
1605
+ cwd: input.message.cwd,
1606
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
1607
+ });
1511
1608
  if (input.message.timeoutMs) {
1512
1609
  timeout = setTimeout(() => {
1513
1610
  subprocess.kill("SIGTERM");
@@ -1545,9 +1642,7 @@ async function executeCommand(input) {
1545
1642
  }
1546
1643
  async function executeStreamingCommand(input) {
1547
1644
  const startedAt = Date.now();
1548
- let timeout;
1549
1645
  let started = false;
1550
- let timedOut = false;
1551
1646
  try {
1552
1647
  if (cancelledProcessRuns.delete(input.message.runId)) {
1553
1648
  sendWorkerMessage(input.ws, {
@@ -1575,6 +1670,14 @@ async function executeStreamingCommand(input) {
1575
1670
  sessionId: input.message.sessionId,
1576
1671
  artifactRoot: input.artifactRoot
1577
1672
  });
1673
+ const planProcessEnv = await preparePlanEnvForShell({
1674
+ baseUrl: input.baseUrl,
1675
+ token: input.token,
1676
+ projectId: input.projectId,
1677
+ branchName: input.message.branchName,
1678
+ planRoot: input.planRoot,
1679
+ activePlanId: input.message.env?.R5D_ACTIVE_PLAN_ID
1680
+ });
1578
1681
  const cwd = resolveCommandCwd(workspace.branchPath, input.message.cwd);
1579
1682
  const subprocess = Bun.spawn(input.message.argv, {
1580
1683
  cwd,
@@ -1585,22 +1688,26 @@ async function executeStreamingCommand(input) {
1585
1688
  ...containerRegistryEnv(),
1586
1689
  ...input.message.env ?? {},
1587
1690
  ...internalGitProcessEnv(workspace, input.message.env),
1588
- ...artifactProcessEnv
1691
+ ...artifactProcessEnv,
1692
+ ...planProcessEnv
1589
1693
  }
1590
1694
  });
1591
- activeProcesses.set(input.message.runId, { process: subprocess, command: input.message.argv });
1695
+ activeProcesses.set(input.message.runId, {
1696
+ process: subprocess,
1697
+ projectId: input.projectId,
1698
+ sessionId: input.message.sessionId,
1699
+ branchName: input.message.branchName,
1700
+ argv: input.message.argv,
1701
+ command: input.message.command,
1702
+ cwd: input.message.cwd,
1703
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
1704
+ });
1592
1705
  started = true;
1593
1706
  sendWorkerMessage(input.ws, {
1594
1707
  type: "exec_started",
1595
1708
  requestId: input.message.requestId,
1596
1709
  runId: input.message.runId
1597
1710
  });
1598
- if (input.message.timeoutMs) {
1599
- timeout = setTimeout(() => {
1600
- timedOut = true;
1601
- subprocess.kill("SIGTERM");
1602
- }, input.message.timeoutMs);
1603
- }
1604
1711
  const [exitCode] = await Promise.all([
1605
1712
  subprocess.exited,
1606
1713
  streamCommandOutput(subprocess.stdout, (data) => {
@@ -1624,8 +1731,7 @@ async function executeStreamingCommand(input) {
1624
1731
  type: "exec_exit",
1625
1732
  runId: input.message.runId,
1626
1733
  exitCode,
1627
- durationMs: Date.now() - startedAt,
1628
- ...timedOut ? { timedOut: true } : {}
1734
+ durationMs: Date.now() - startedAt
1629
1735
  });
1630
1736
  } catch (error) {
1631
1737
  const message = error instanceof Error ? error.message : String(error);
@@ -1645,15 +1751,36 @@ async function executeStreamingCommand(input) {
1645
1751
  });
1646
1752
  }
1647
1753
  } finally {
1648
- if (timeout) {
1649
- clearTimeout(timeout);
1650
- }
1651
1754
  activeProcesses.delete(input.message.runId);
1652
1755
  cancelledProcessRuns.delete(input.message.runId);
1653
1756
  }
1654
1757
  }
1655
1758
  function sendWorkerMessage(ws, message) {
1656
- ws.send(JSON.stringify(message));
1759
+ const target = currentWorkerSocket ?? ws;
1760
+ try {
1761
+ target.send(JSON.stringify(message));
1762
+ } catch (error) {
1763
+ process.stderr.write(`[r5d-worker] failed to send ${message.type}: ${error instanceof Error ? error.message : String(error)}
1764
+ `);
1765
+ }
1766
+ }
1767
+ function buildActiveProcessReports() {
1768
+ return Array.from(activeProcesses.entries()).filter(([, active]) => active.sessionId.length > 0).map(([runId, active]) => ({
1769
+ runId,
1770
+ projectId: active.projectId,
1771
+ sessionId: active.sessionId,
1772
+ branchName: active.branchName,
1773
+ argv: active.argv,
1774
+ command: active.command,
1775
+ ...active.cwd ? { cwd: active.cwd } : {},
1776
+ startedAt: active.startedAt
1777
+ }));
1778
+ }
1779
+ function sendActiveProcessReport(ws) {
1780
+ sendWorkerMessage(ws, {
1781
+ type: "active_processes",
1782
+ processes: buildActiveProcessReports()
1783
+ });
1657
1784
  }
1658
1785
  const PTY_BRIDGE_SCRIPT = String.raw`
1659
1786
  const readline = require("node:readline");
@@ -1837,7 +1964,7 @@ function resolveHostShell() {
1837
1964
  }
1838
1965
  return { file: process.env.SHELL || "/bin/bash", args: ["-l"] };
1839
1966
  }
1840
- function openPty(input) {
1967
+ async function openPty(input) {
1841
1968
  try {
1842
1969
  const branchPath = ensureBranchWorkspace({
1843
1970
  projectId: input.projectId,
@@ -1848,6 +1975,14 @@ function openPty(input) {
1848
1975
  branchName: input.message.branchName,
1849
1976
  manifest: input.manifest
1850
1977
  });
1978
+ const planProcessEnv = await preparePlanEnvForShell({
1979
+ baseUrl: input.baseUrl,
1980
+ token: input.token,
1981
+ projectId: input.projectId,
1982
+ branchName: input.message.branchName,
1983
+ planRoot: input.planRoot,
1984
+ activePlanId: input.message.env?.R5D_ACTIVE_PLAN_ID
1985
+ });
1851
1986
  const shell = resolveHostShell();
1852
1987
  const ptyProcess = createNodePtyBridge({
1853
1988
  file: shell.file,
@@ -1861,6 +1996,7 @@ function openPty(input) {
1861
1996
  ...process.env,
1862
1997
  ...containerRegistryEnv(),
1863
1998
  ...input.message.env ?? {},
1999
+ ...planProcessEnv,
1864
2000
  R5D_PROJECT_ID: input.projectId,
1865
2001
  R5D_BRANCH_NAME: input.message.branchName
1866
2002
  }
@@ -1961,6 +2097,7 @@ async function startWorker(options) {
1961
2097
  const projectsRoot = path.resolve(options.projectRoot ?? defaultProjectsRoot());
1962
2098
  const syncRoot = path.resolve(options.syncRoot ?? process.env.R5D_SYNC_ROOT ?? defaultSyncRoot());
1963
2099
  const artifactRoot = path.resolve(options.artifactRoot ?? process.env.R5D_ARTIFACTS_ROOT ?? defaultArtifactRoot());
2100
+ const planRoot = path.resolve(options.planRoot ?? process.env.R5D_PLANS_ROOT ?? defaultPlanRoot());
1964
2101
  process.stdout.write(`[r5d-worker] label: ${label}
1965
2102
  `);
1966
2103
  process.stdout.write(`[r5d-worker] projects root: ${projectsRoot}
@@ -1968,6 +2105,8 @@ async function startWorker(options) {
1968
2105
  process.stdout.write(`[r5d-worker] sync root: ${syncRoot}
1969
2106
  `);
1970
2107
  process.stdout.write(`[r5d-worker] artifact root: ${artifactRoot}
2108
+ `);
2109
+ process.stdout.write(`[r5d-worker] plan root: ${planRoot}
1971
2110
  `);
1972
2111
  process.stdout.write(`[r5d-worker] server: ${baseUrl}
1973
2112
  `);
@@ -1976,6 +2115,7 @@ async function startWorker(options) {
1976
2115
  fs.mkdirSync(projectsRoot, { recursive: true });
1977
2116
  fs.mkdirSync(syncRoot, { recursive: true });
1978
2117
  fs.mkdirSync(artifactRoot, { recursive: true });
2118
+ fs.mkdirSync(planRoot, { recursive: true });
1979
2119
  const manifestByProjectId = /* @__PURE__ */ new Map();
1980
2120
  const ws = new WebSocket(websocketUrl(baseUrl, label), {
1981
2121
  headers: {
@@ -1983,6 +2123,7 @@ async function startWorker(options) {
1983
2123
  }
1984
2124
  });
1985
2125
  ws.addEventListener("open", () => {
2126
+ currentWorkerSocket = ws;
1986
2127
  const hello = {
1987
2128
  type: "hello",
1988
2129
  hostInfo: {
@@ -1992,10 +2133,12 @@ async function startWorker(options) {
1992
2133
  pid: process.pid,
1993
2134
  version: getWorkerVersion(),
1994
2135
  projectRoot: projectsRoot,
1995
- artifactRoot
2136
+ artifactRoot,
2137
+ planRoot
1996
2138
  }
1997
2139
  };
1998
2140
  ws.send(JSON.stringify(hello));
2141
+ sendActiveProcessReport(ws);
1999
2142
  process.stdout.write("[r5d-worker] connected\n");
2000
2143
  });
2001
2144
  ws.addEventListener("message", (event) => {
@@ -2018,17 +2161,29 @@ async function startWorker(options) {
2018
2161
  const branches = project.branches.length > 0 ? project.branches : [project.defaultBranch || "main"];
2019
2162
  for (const branchName of branches) {
2020
2163
  try {
2021
- await withBranchLock(project.projectId, branchName, async () => {
2022
- ensureBranchWorkspace({
2023
- projectId: project.projectId,
2164
+ ensureBranchWorkspace({
2165
+ projectId: project.projectId,
2166
+ baseUrl,
2167
+ token,
2168
+ projectRoot,
2169
+ syncRoot,
2170
+ branchName,
2171
+ manifest: project
2172
+ });
2173
+ try {
2174
+ await syncProjectPlans({
2024
2175
  baseUrl,
2025
2176
  token,
2026
- projectRoot,
2027
- syncRoot,
2177
+ projectId: project.projectId,
2028
2178
  branchName,
2029
- manifest: project
2179
+ planRoot
2030
2180
  });
2031
- });
2181
+ } catch (error) {
2182
+ process.stderr.write(
2183
+ `[r5d-worker] plan sync skipped for ${project.projectId}/${branchName}: ${error instanceof Error ? error.message : String(error)}
2184
+ `
2185
+ );
2186
+ }
2032
2187
  } catch (error) {
2033
2188
  process.stderr.write(
2034
2189
  `[r5d-worker] failed to sync ${project.repoSlug}/${branchName}: ${error instanceof Error ? error.message : String(error)}
@@ -2056,7 +2211,7 @@ async function startWorker(options) {
2056
2211
  requestId: message.requestId,
2057
2212
  runId: message.runId,
2058
2213
  cancelled: true,
2059
- message: active ? `Sent SIGTERM to ${active.command.join(" ")}` : "Cancellation queued before command start"
2214
+ message: active ? `Sent SIGTERM to ${active.command}` : "Cancellation queued before command start"
2060
2215
  })
2061
2216
  );
2062
2217
  return;
@@ -2066,7 +2221,7 @@ async function startWorker(options) {
2066
2221
  const projectRoot = projectRootFor(projectsRoot, message.projectId, manifestByProjectId);
2067
2222
  process.stdout.write(`[r5d-worker] pty ${message.ptyId}: ${message.projectId}/${message.branchName}
2068
2223
  `);
2069
- openPty({ ws, message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, manifest });
2224
+ await openPty({ ws, message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, planRoot, manifest });
2070
2225
  return;
2071
2226
  }
2072
2227
  if (message.type === "pty_input") {
@@ -2086,11 +2241,17 @@ async function startWorker(options) {
2086
2241
  const projectRoot = projectRootFor(projectsRoot, message.projectId, manifestByProjectId);
2087
2242
  process.stdout.write(`[r5d-worker] exec ${message.runId}: ${message.argv.join(" ")}
2088
2243
  `);
2089
- const result = await withBranchLock(
2090
- message.projectId,
2091
- message.branchName,
2092
- () => executeCommand({ message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, artifactRoot, manifest })
2093
- );
2244
+ const result = await executeCommand({
2245
+ message,
2246
+ projectId: message.projectId,
2247
+ baseUrl,
2248
+ token,
2249
+ projectRoot,
2250
+ syncRoot,
2251
+ artifactRoot,
2252
+ planRoot,
2253
+ manifest
2254
+ });
2094
2255
  ws.send(JSON.stringify(result));
2095
2256
  return;
2096
2257
  }
@@ -2105,11 +2266,18 @@ async function startWorker(options) {
2105
2266
  const projectRoot = projectRootFor(projectsRoot, message.projectId, manifestByProjectId);
2106
2267
  process.stdout.write(`[r5d-worker] exec_start ${message.runId}: ${message.argv.join(" ")}
2107
2268
  `);
2108
- void withBranchLock(
2109
- message.projectId,
2110
- message.branchName,
2111
- () => executeStreamingCommand({ ws, message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, artifactRoot, manifest })
2112
- ).catch((error) => {
2269
+ void executeStreamingCommand({
2270
+ ws,
2271
+ message,
2272
+ projectId: message.projectId,
2273
+ baseUrl,
2274
+ token,
2275
+ projectRoot,
2276
+ syncRoot,
2277
+ artifactRoot,
2278
+ planRoot,
2279
+ manifest
2280
+ }).catch((error) => {
2113
2281
  sendWorkerMessage(ws, {
2114
2282
  type: "exec_start_error",
2115
2283
  requestId: message.requestId,
@@ -2131,11 +2299,16 @@ async function startWorker(options) {
2131
2299
  try {
2132
2300
  const manifest = manifestByProjectId.get(message.projectId);
2133
2301
  const projectRoot = projectRootFor(projectsRoot, message.projectId, manifestByProjectId);
2134
- const result = await withBranchLock(
2135
- message.projectId,
2136
- message.branchName,
2137
- () => executeOperation({ message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, artifactRoot, manifest })
2138
- );
2302
+ const result = await executeOperation({
2303
+ message,
2304
+ projectId: message.projectId,
2305
+ baseUrl,
2306
+ token,
2307
+ projectRoot,
2308
+ syncRoot,
2309
+ artifactRoot,
2310
+ manifest
2311
+ });
2139
2312
  ws.send(
2140
2313
  JSON.stringify({
2141
2314
  type: "operation_result",
@@ -2170,10 +2343,31 @@ async function startWorker(options) {
2170
2343
  });
2171
2344
  });
2172
2345
  ws.addEventListener("close", (event) => {
2346
+ if (currentWorkerSocket === ws) {
2347
+ currentWorkerSocket = null;
2348
+ }
2173
2349
  closeAllPtys();
2174
2350
  const reason = event.reason ? `: ${event.reason}` : "";
2175
2351
  process.stderr.write(`[r5d-worker] disconnected (${event.code}${reason})
2176
2352
  `);
2353
+ if (activeProcesses.size > 0) {
2354
+ const delayMs = 2e3;
2355
+ process.stderr.write(`[r5d-worker] ${activeProcesses.size} process(es) still active; reconnecting in ${delayMs}ms
2356
+ `);
2357
+ const reconnect = () => {
2358
+ void startWorker(options).catch((error) => {
2359
+ process.stderr.write(`[r5d-worker] reconnect failed: ${error instanceof Error ? error.message : String(error)}
2360
+ `);
2361
+ if (activeProcesses.size > 0) {
2362
+ setTimeout(reconnect, delayMs);
2363
+ return;
2364
+ }
2365
+ process.exit(1);
2366
+ });
2367
+ };
2368
+ setTimeout(reconnect, delayMs);
2369
+ return;
2370
+ }
2177
2371
  process.exit(event.code === 1e3 ? 0 : 1);
2178
2372
  });
2179
2373
  ws.addEventListener("error", (event) => {
@@ -2213,6 +2407,8 @@ export {
2213
2407
  ensureVisibleGitCheckout,
2214
2408
  isArtifactEnvPath,
2215
2409
  prepareArtifactEnvForShell,
2410
+ preparePlanEnvForShell,
2216
2411
  resolveProjectFilePath,
2412
+ syncProjectPlans,
2217
2413
  syncSessionArtifacts
2218
2414
  };