@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/cjs/main.cjs CHANGED
@@ -32,7 +32,9 @@ __export(main_exports, {
32
32
  ensureVisibleGitCheckout: () => ensureVisibleGitCheckout,
33
33
  isArtifactEnvPath: () => isArtifactEnvPath,
34
34
  prepareArtifactEnvForShell: () => prepareArtifactEnvForShell,
35
+ preparePlanEnvForShell: () => preparePlanEnvForShell,
35
36
  resolveProjectFilePath: () => resolveProjectFilePath,
37
+ syncProjectPlans: () => syncProjectPlans,
36
38
  syncSessionArtifacts: () => syncSessionArtifacts
37
39
  });
38
40
  module.exports = __toCommonJS(main_exports);
@@ -56,7 +58,7 @@ const LEGACY_BUILD_IT_NOW_REMOTE_NAME = "build-it-now";
56
58
  const activeProcesses = /* @__PURE__ */ new Map();
57
59
  const cancelledProcessRuns = /* @__PURE__ */ new Set();
58
60
  const activePtys = /* @__PURE__ */ new Map();
59
- const branchLocks = /* @__PURE__ */ new Map();
61
+ let currentWorkerSocket = null;
60
62
  let containerRegistryCredential = null;
61
63
  let visibleGitIdentity = null;
62
64
  function defaultConfigPath() {
@@ -71,6 +73,9 @@ function defaultSyncRoot() {
71
73
  function defaultArtifactRoot() {
72
74
  return import_node_path.default.join(import_node_os.default.homedir(), ".r5d", "artifacts");
73
75
  }
76
+ function defaultPlanRoot() {
77
+ return import_node_path.default.join(import_node_os.default.homedir(), ".r5d", "plans");
78
+ }
74
79
  function printHelp() {
75
80
  process.stdout.write(`Usage:
76
81
  r5d-worker start --label <label> [--base-url <url>] [--token <token>] [--api-key <key>]
@@ -85,6 +90,7 @@ Options:
85
90
  --project-root <dir> Override projects checkout root (default ~/.r5d/projects)
86
91
  --sync-root <dir> Override r5d internal sync git root (default ~/.r5d/sync)
87
92
  --artifact-root <dir> Override session artifact root (default from R5D_ARTIFACTS_ROOT or ~/.r5d/artifacts)
93
+ --plan-root <dir> Override synced plan root (default from R5D_PLANS_ROOT or ~/.r5d/plans)
88
94
  -v, --version Show r5d-worker version
89
95
  -h, --help Show this help
90
96
  `);
@@ -206,6 +212,11 @@ function parseArgs(argv) {
206
212
  options.artifactRoot = artifactRoot;
207
213
  continue;
208
214
  }
215
+ const planRoot = readOption("--plan-root");
216
+ if (planRoot !== void 0) {
217
+ options.planRoot = planRoot;
218
+ continue;
219
+ }
209
220
  rest.push(arg);
210
221
  }
211
222
  if (options.version) {
@@ -249,6 +260,7 @@ async function readResponseText(response) {
249
260
  }
250
261
  }
251
262
  const R5D_ARTIFACTS_DIR_REF = "$R5D_ARTIFACTS_DIR";
263
+ const R5D_PLANS_DIR_ENV = "R5D_PLANS_DIR";
252
264
  function validateArtifactSessionId(sessionId) {
253
265
  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)) {
254
266
  throw new Error(`Invalid artifact session id: ${sessionId}`);
@@ -257,7 +269,7 @@ function validateArtifactSessionId(sessionId) {
257
269
  function assertInsideRoot(rootPath, candidatePath, label) {
258
270
  const relative = import_node_path.default.relative(rootPath, candidatePath);
259
271
  if (relative === ".." || relative.startsWith(`..${import_node_path.default.sep}`) || import_node_path.default.isAbsolute(relative)) {
260
- throw new Error(`${label} escapes the artifact directory`);
272
+ throw new Error(`${label} escapes sync directory`);
261
273
  }
262
274
  }
263
275
  function sessionArtifactDir(artifactRoot, sessionId) {
@@ -272,6 +284,23 @@ function artifactEnv(artifactRoot, sessionId) {
272
284
  R5D_ARTIFACTS_DIR: sessionArtifactDir(artifactRoot, sessionId)
273
285
  };
274
286
  }
287
+ function projectPlanDir(planRoot, projectId, branchName) {
288
+ validateProjectId(projectId);
289
+ validateBranchName(branchName);
290
+ return import_node_path.default.join(planRoot, projectId, branchName);
291
+ }
292
+ function planEnv(planRoot, projectId, branchName, activePlanId) {
293
+ const plansDir = projectPlanDir(planRoot, projectId, branchName);
294
+ const env = {
295
+ [R5D_PLANS_DIR_ENV]: plansDir
296
+ };
297
+ if (activePlanId) {
298
+ validatePlanId(activePlanId);
299
+ env.R5D_ACTIVE_PLAN_ID = activePlanId;
300
+ env.R5D_ACTIVE_PLAN_FILE = import_node_path.default.join(plansDir, `${activePlanId}.plan.md`);
301
+ }
302
+ return env;
303
+ }
275
304
  function isArtifactEnvPath(filePath) {
276
305
  return filePath === R5D_ARTIFACTS_DIR_REF || filePath.startsWith(`${R5D_ARTIFACTS_DIR_REF}/`);
277
306
  }
@@ -284,99 +313,150 @@ function artifactManifestUrl(baseUrl, projectId, branchName, sessionId) {
284
313
  function artifactDownloadUrl(baseUrl, projectId, branchName, sessionId, filename) {
285
314
  return new URL(`${artifactApiBasePath(projectId, branchName, sessionId)}/${encodeURIComponent(filename)}`, baseUrl);
286
315
  }
316
+ function planApiBasePath(projectId, branchName) {
317
+ return `/preview-api/${encodeURIComponent(projectId)}/${encodeURIComponent(branchName)}/plans`;
318
+ }
319
+ function planManifestUrl(baseUrl, projectId, branchName) {
320
+ return new URL(planApiBasePath(projectId, branchName), baseUrl);
321
+ }
322
+ function planDownloadUrl(baseUrl, projectId, branchName, filename) {
323
+ return new URL(`${planApiBasePath(projectId, branchName)}/${encodeURIComponent(filename)}`, baseUrl);
324
+ }
287
325
  function sha256Path(filePath) {
288
326
  return (0, import_node_crypto.createHash)("sha256").update(import_node_fs.default.readFileSync(filePath)).digest("hex");
289
327
  }
290
- function parseSessionArtifactManifest(value) {
291
- if (!value || typeof value !== "object" || !Array.isArray(value.artifacts)) {
292
- throw new Error("Artifact manifest response is invalid");
328
+ function parseRemoteFileManifest(value, collectionKey, label) {
329
+ if (!value || typeof value !== "object" || !Array.isArray(value[collectionKey])) {
330
+ throw new Error(`${label} manifest response is invalid`);
293
331
  }
294
- return value.artifacts.map((entry) => {
332
+ return value[collectionKey].map((entry) => {
295
333
  if (!entry || typeof entry !== "object") {
296
- throw new Error("Artifact manifest entry is invalid");
334
+ throw new Error(`${label} manifest entry is invalid`);
297
335
  }
298
- const artifact = entry;
299
- if (typeof artifact.filename !== "string" || artifact.filename.includes("/") || artifact.filename.includes("\\")) {
300
- throw new Error("Artifact manifest entry has an invalid filename");
336
+ const file = entry;
337
+ if (typeof file.filename !== "string" || file.filename.includes("/") || file.filename.includes("\\")) {
338
+ throw new Error(`${label} manifest entry has an invalid filename`);
301
339
  }
302
- if (typeof artifact.size !== "number" || !Number.isFinite(artifact.size) || artifact.size < 0) {
303
- throw new Error(`Artifact manifest entry has an invalid size: ${artifact.filename}`);
340
+ if (typeof file.size !== "number" || !Number.isFinite(file.size) || file.size < 0) {
341
+ throw new Error(`${label} manifest entry has an invalid size: ${file.filename}`);
304
342
  }
305
- if (typeof artifact.sha256 !== "string" || !/^[a-f0-9]{64}$/i.test(artifact.sha256)) {
306
- throw new Error(`Artifact manifest entry has an invalid sha256: ${artifact.filename}`);
343
+ if (typeof file.sha256 !== "string" || !/^[a-f0-9]{64}$/i.test(file.sha256)) {
344
+ throw new Error(`${label} manifest entry has an invalid sha256: ${file.filename}`);
307
345
  }
308
346
  return {
309
- filename: artifact.filename,
310
- size: artifact.size,
311
- sha256: artifact.sha256.toLowerCase()
347
+ filename: file.filename,
348
+ size: file.size,
349
+ sha256: file.sha256.toLowerCase()
312
350
  };
313
351
  });
314
352
  }
315
- async function fetchSessionArtifactManifest(input) {
316
- const response = await fetch(artifactManifestUrl(input.baseUrl, input.projectId, input.branchName, input.sessionId), {
353
+ async function fetchRemoteFileManifest(input) {
354
+ const response = await fetch(input.url, {
317
355
  headers: {
318
356
  Authorization: `Bearer ${input.token}`
319
357
  }
320
358
  });
321
359
  if (!response.ok) {
322
360
  const detail = (await readResponseText(response)).trim();
323
- throw new Error(`Failed to fetch artifact manifest (${response.status})${detail ? `: ${detail}` : ""}`);
361
+ throw new Error(`Failed to fetch ${input.label} manifest (${response.status})${detail ? `: ${detail}` : ""}`);
324
362
  }
325
- return parseSessionArtifactManifest(await response.json());
363
+ return parseRemoteFileManifest(await response.json(), input.collectionKey, input.label);
326
364
  }
327
- async function downloadSessionArtifact(input) {
328
- const targetDir = sessionArtifactDir(input.artifactRoot, input.sessionId);
329
- const targetPath = import_node_path.default.join(targetDir, input.artifact.filename);
330
- assertInsideRoot(targetDir, targetPath, "Artifact path");
331
- const response = await fetch(
332
- artifactDownloadUrl(input.baseUrl, input.projectId, input.branchName, input.sessionId, input.artifact.filename),
333
- {
334
- headers: {
335
- Authorization: `Bearer ${input.token}`
336
- }
365
+ async function downloadRemoteSyncedFile(input) {
366
+ const targetPath = import_node_path.default.join(input.targetDir, input.file.filename);
367
+ assertInsideRoot(input.targetDir, targetPath, input.label);
368
+ const response = await fetch(input.url, {
369
+ headers: {
370
+ Authorization: `Bearer ${input.token}`
337
371
  }
338
- );
372
+ });
339
373
  if (!response.ok) {
340
374
  const detail = (await readResponseText(response)).trim();
341
- throw new Error(`Failed to download artifact ${input.artifact.filename} (${response.status})${detail ? `: ${detail}` : ""}`);
375
+ throw new Error(`Failed to download ${input.label} ${input.file.filename} (${response.status})${detail ? `: ${detail}` : ""}`);
342
376
  }
343
377
  const buffer = Buffer.from(await response.arrayBuffer());
344
378
  const receivedHash = (0, import_node_crypto.createHash)("sha256").update(buffer).digest("hex");
345
- if (receivedHash !== input.artifact.sha256) {
346
- throw new Error(`Downloaded artifact ${input.artifact.filename} failed sha256 verification`);
379
+ if (receivedHash !== input.file.sha256) {
380
+ throw new Error(`Downloaded ${input.label} ${input.file.filename} failed sha256 verification`);
347
381
  }
348
- if (buffer.length !== input.artifact.size) {
349
- throw new Error(`Downloaded artifact ${input.artifact.filename} has unexpected size`);
382
+ if (buffer.length !== input.file.size) {
383
+ throw new Error(`Downloaded ${input.label} ${input.file.filename} has unexpected size`);
350
384
  }
351
385
  const tempPath = `${targetPath}.${process.pid}.${Date.now()}.tmp`;
352
386
  import_node_fs.default.writeFileSync(tempPath, buffer);
353
387
  import_node_fs.default.renameSync(tempPath, targetPath);
354
388
  }
355
- async function syncSessionArtifacts(input) {
356
- const targetDir = sessionArtifactDir(input.artifactRoot, input.sessionId);
357
- import_node_fs.default.mkdirSync(targetDir, { recursive: true });
358
- const artifacts = await fetchSessionArtifactManifest(input);
359
- const manifestNames = new Set(artifacts.map((artifact) => artifact.filename));
360
- for (const artifact of artifacts) {
361
- const localPath = import_node_path.default.join(targetDir, artifact.filename);
362
- assertInsideRoot(targetDir, localPath, "Artifact path");
389
+ async function syncRemoteFileSet(input) {
390
+ import_node_fs.default.mkdirSync(input.targetDir, { recursive: true });
391
+ const manifestNames = new Set(input.files.map((file) => file.filename));
392
+ for (const file of input.files) {
393
+ const localPath = import_node_path.default.join(input.targetDir, file.filename);
394
+ assertInsideRoot(input.targetDir, localPath, input.label);
363
395
  if (import_node_fs.default.existsSync(localPath)) {
364
396
  const stats = import_node_fs.default.statSync(localPath);
365
- if (stats.isFile() && stats.size === artifact.size && sha256Path(localPath) === artifact.sha256) {
397
+ if (stats.isFile() && stats.size === file.size && sha256Path(localPath) === file.sha256) {
366
398
  continue;
367
399
  }
368
400
  import_node_fs.default.rmSync(localPath, { recursive: true, force: true });
369
401
  }
370
- await downloadSessionArtifact({ ...input, artifact });
402
+ await input.download(file);
371
403
  }
372
- for (const localName of import_node_fs.default.readdirSync(targetDir)) {
404
+ for (const localName of import_node_fs.default.readdirSync(input.targetDir)) {
373
405
  if (!manifestNames.has(localName)) {
374
- const localPath = import_node_path.default.join(targetDir, localName);
375
- assertInsideRoot(targetDir, localPath, "Artifact path");
406
+ const localPath = import_node_path.default.join(input.targetDir, localName);
407
+ assertInsideRoot(input.targetDir, localPath, input.label);
376
408
  import_node_fs.default.rmSync(localPath, { recursive: true, force: true });
377
409
  }
378
410
  }
379
- return targetDir;
411
+ return input.targetDir;
412
+ }
413
+ async function fetchSessionArtifactManifest(input) {
414
+ return fetchRemoteFileManifest({
415
+ url: artifactManifestUrl(input.baseUrl, input.projectId, input.branchName, input.sessionId),
416
+ token: input.token,
417
+ collectionKey: "artifacts",
418
+ label: "artifact"
419
+ });
420
+ }
421
+ async function syncSessionArtifacts(input) {
422
+ const targetDir = sessionArtifactDir(input.artifactRoot, input.sessionId);
423
+ const artifacts = await fetchSessionArtifactManifest(input);
424
+ return syncRemoteFileSet({
425
+ targetDir,
426
+ files: artifacts,
427
+ label: "Artifact path",
428
+ download: (artifact) => downloadRemoteSyncedFile({
429
+ url: artifactDownloadUrl(input.baseUrl, input.projectId, input.branchName, input.sessionId, artifact.filename),
430
+ token: input.token,
431
+ targetDir,
432
+ file: artifact,
433
+ label: "artifact"
434
+ })
435
+ });
436
+ }
437
+ async function fetchProjectPlanManifest(input) {
438
+ return fetchRemoteFileManifest({
439
+ url: planManifestUrl(input.baseUrl, input.projectId, input.branchName),
440
+ token: input.token,
441
+ collectionKey: "plans",
442
+ label: "plan"
443
+ });
444
+ }
445
+ async function syncProjectPlans(input) {
446
+ const targetDir = projectPlanDir(input.planRoot, input.projectId, input.branchName);
447
+ const plans = await fetchProjectPlanManifest(input);
448
+ return syncRemoteFileSet({
449
+ targetDir,
450
+ files: plans,
451
+ label: "Plan path",
452
+ download: (plan) => downloadRemoteSyncedFile({
453
+ url: planDownloadUrl(input.baseUrl, input.projectId, input.branchName, plan.filename),
454
+ token: input.token,
455
+ targetDir,
456
+ file: plan,
457
+ label: "plan"
458
+ })
459
+ });
380
460
  }
381
461
  async function resolveArtifactEnvFilePath(input) {
382
462
  if (!isArtifactEnvPath(input.filePath)) {
@@ -435,6 +515,19 @@ async function prepareArtifactEnvForShell(input) {
435
515
  R5D_ARTIFACTS_DIR: artifactsDir
436
516
  };
437
517
  }
518
+ async function preparePlanEnvForShell(input) {
519
+ try {
520
+ await syncProjectPlans(input);
521
+ } catch (error) {
522
+ process.stderr.write(
523
+ `[r5d-worker] plan sync skipped for ${input.projectId}/${input.branchName}: ${error instanceof Error ? error.message : String(error)}
524
+ `
525
+ );
526
+ }
527
+ const plansDir = projectPlanDir(input.planRoot, input.projectId, input.branchName);
528
+ import_node_fs.default.mkdirSync(plansDir, { recursive: true });
529
+ return planEnv(input.planRoot, input.projectId, input.branchName, input.activePlanId);
530
+ }
438
531
  async function verifyR5dctlAuth(baseUrl, token) {
439
532
  const statusUrl = new URL("/api/r5dctl/auth/status", baseUrl);
440
533
  let response;
@@ -479,11 +572,24 @@ function validateLabel(label) {
479
572
  throw new Error("Worker label must start with a letter or number and may contain letters, numbers, dots, underscores, and hyphens");
480
573
  }
481
574
  }
575
+ function validateProjectId(projectId) {
576
+ if (projectId === "local" || projectId === "local-code") {
577
+ return;
578
+ }
579
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(projectId)) {
580
+ throw new Error(`Invalid project id from server: ${projectId}`);
581
+ }
582
+ }
482
583
  function validateBranchName(branchName) {
483
584
  if (branchName !== "main" && !BRANCH_RE.test(branchName)) {
484
585
  throw new Error(`Invalid branch name from server: ${branchName}`);
485
586
  }
486
587
  }
588
+ function validatePlanId(planId) {
589
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(planId)) {
590
+ throw new Error(`Invalid plan id from server: ${planId}`);
591
+ }
592
+ }
487
593
  function gitExtraHeaderConfigKey(extraHeaderUrl) {
488
594
  return `http.${normalizeBaseUrl(extraHeaderUrl)}/.extraHeader`;
489
595
  }
@@ -584,31 +690,6 @@ function resolveProjectFilePath(branchPath, virtualPath) {
584
690
  assertInsideBranch(branchPath, absolutePath);
585
691
  return { absolutePath, virtualPath: repoRelativePath ? `/${repoRelativePath}` : "/", repoRelativePath };
586
692
  }
587
- function branchLockKey(projectId, branchName) {
588
- return `${projectId}:${branchName}`;
589
- }
590
- async function withBranchLock(projectId, branchName, fn) {
591
- const key = branchLockKey(projectId, branchName);
592
- const previous = branchLocks.get(key) ?? Promise.resolve();
593
- let release = () => {
594
- };
595
- const next = new Promise((resolve) => {
596
- release = resolve;
597
- });
598
- const queued = previous.catch(() => {
599
- }).then(() => next);
600
- branchLocks.set(key, queued);
601
- await previous.catch(() => {
602
- });
603
- try {
604
- return await fn();
605
- } finally {
606
- release();
607
- if (branchLocks.get(key) === queued) {
608
- branchLocks.delete(key);
609
- }
610
- }
611
- }
612
693
  function resolveRemoteUrl(baseUrl, remoteUrl) {
613
694
  if (/^https?:\/\//i.test(remoteUrl)) {
614
695
  return remoteUrl;
@@ -1532,6 +1613,14 @@ async function executeCommand(input) {
1532
1613
  sessionId: input.message.sessionId,
1533
1614
  artifactRoot: input.artifactRoot
1534
1615
  });
1616
+ const planProcessEnv = await preparePlanEnvForShell({
1617
+ baseUrl: input.baseUrl,
1618
+ token: input.token,
1619
+ projectId: input.projectId,
1620
+ branchName: input.message.branchName,
1621
+ planRoot: input.planRoot,
1622
+ activePlanId: input.message.env?.R5D_ACTIVE_PLAN_ID
1623
+ });
1535
1624
  const cwd = resolveCommandCwd(workspace.branchPath, input.message.cwd);
1536
1625
  const subprocess = Bun.spawn(input.message.argv, {
1537
1626
  cwd,
@@ -1542,10 +1631,20 @@ async function executeCommand(input) {
1542
1631
  ...containerRegistryEnv(),
1543
1632
  ...input.message.env ?? {},
1544
1633
  ...internalGitProcessEnv(workspace, input.message.env),
1545
- ...artifactProcessEnv
1634
+ ...artifactProcessEnv,
1635
+ ...planProcessEnv
1546
1636
  }
1547
1637
  });
1548
- activeProcesses.set(input.message.runId, { process: subprocess, command: input.message.argv });
1638
+ activeProcesses.set(input.message.runId, {
1639
+ process: subprocess,
1640
+ projectId: input.projectId,
1641
+ sessionId: input.message.sessionId ?? "",
1642
+ branchName: input.message.branchName,
1643
+ argv: input.message.argv,
1644
+ command: input.message.argv.join(" "),
1645
+ cwd: input.message.cwd,
1646
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
1647
+ });
1549
1648
  if (input.message.timeoutMs) {
1550
1649
  timeout = setTimeout(() => {
1551
1650
  subprocess.kill("SIGTERM");
@@ -1583,9 +1682,7 @@ async function executeCommand(input) {
1583
1682
  }
1584
1683
  async function executeStreamingCommand(input) {
1585
1684
  const startedAt = Date.now();
1586
- let timeout;
1587
1685
  let started = false;
1588
- let timedOut = false;
1589
1686
  try {
1590
1687
  if (cancelledProcessRuns.delete(input.message.runId)) {
1591
1688
  sendWorkerMessage(input.ws, {
@@ -1613,6 +1710,14 @@ async function executeStreamingCommand(input) {
1613
1710
  sessionId: input.message.sessionId,
1614
1711
  artifactRoot: input.artifactRoot
1615
1712
  });
1713
+ const planProcessEnv = await preparePlanEnvForShell({
1714
+ baseUrl: input.baseUrl,
1715
+ token: input.token,
1716
+ projectId: input.projectId,
1717
+ branchName: input.message.branchName,
1718
+ planRoot: input.planRoot,
1719
+ activePlanId: input.message.env?.R5D_ACTIVE_PLAN_ID
1720
+ });
1616
1721
  const cwd = resolveCommandCwd(workspace.branchPath, input.message.cwd);
1617
1722
  const subprocess = Bun.spawn(input.message.argv, {
1618
1723
  cwd,
@@ -1623,22 +1728,26 @@ async function executeStreamingCommand(input) {
1623
1728
  ...containerRegistryEnv(),
1624
1729
  ...input.message.env ?? {},
1625
1730
  ...internalGitProcessEnv(workspace, input.message.env),
1626
- ...artifactProcessEnv
1731
+ ...artifactProcessEnv,
1732
+ ...planProcessEnv
1627
1733
  }
1628
1734
  });
1629
- activeProcesses.set(input.message.runId, { process: subprocess, command: input.message.argv });
1735
+ activeProcesses.set(input.message.runId, {
1736
+ process: subprocess,
1737
+ projectId: input.projectId,
1738
+ sessionId: input.message.sessionId,
1739
+ branchName: input.message.branchName,
1740
+ argv: input.message.argv,
1741
+ command: input.message.command,
1742
+ cwd: input.message.cwd,
1743
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
1744
+ });
1630
1745
  started = true;
1631
1746
  sendWorkerMessage(input.ws, {
1632
1747
  type: "exec_started",
1633
1748
  requestId: input.message.requestId,
1634
1749
  runId: input.message.runId
1635
1750
  });
1636
- if (input.message.timeoutMs) {
1637
- timeout = setTimeout(() => {
1638
- timedOut = true;
1639
- subprocess.kill("SIGTERM");
1640
- }, input.message.timeoutMs);
1641
- }
1642
1751
  const [exitCode] = await Promise.all([
1643
1752
  subprocess.exited,
1644
1753
  streamCommandOutput(subprocess.stdout, (data) => {
@@ -1662,8 +1771,7 @@ async function executeStreamingCommand(input) {
1662
1771
  type: "exec_exit",
1663
1772
  runId: input.message.runId,
1664
1773
  exitCode,
1665
- durationMs: Date.now() - startedAt,
1666
- ...timedOut ? { timedOut: true } : {}
1774
+ durationMs: Date.now() - startedAt
1667
1775
  });
1668
1776
  } catch (error) {
1669
1777
  const message = error instanceof Error ? error.message : String(error);
@@ -1683,15 +1791,36 @@ async function executeStreamingCommand(input) {
1683
1791
  });
1684
1792
  }
1685
1793
  } finally {
1686
- if (timeout) {
1687
- clearTimeout(timeout);
1688
- }
1689
1794
  activeProcesses.delete(input.message.runId);
1690
1795
  cancelledProcessRuns.delete(input.message.runId);
1691
1796
  }
1692
1797
  }
1693
1798
  function sendWorkerMessage(ws, message) {
1694
- ws.send(JSON.stringify(message));
1799
+ const target = currentWorkerSocket ?? ws;
1800
+ try {
1801
+ target.send(JSON.stringify(message));
1802
+ } catch (error) {
1803
+ process.stderr.write(`[r5d-worker] failed to send ${message.type}: ${error instanceof Error ? error.message : String(error)}
1804
+ `);
1805
+ }
1806
+ }
1807
+ function buildActiveProcessReports() {
1808
+ return Array.from(activeProcesses.entries()).filter(([, active]) => active.sessionId.length > 0).map(([runId, active]) => ({
1809
+ runId,
1810
+ projectId: active.projectId,
1811
+ sessionId: active.sessionId,
1812
+ branchName: active.branchName,
1813
+ argv: active.argv,
1814
+ command: active.command,
1815
+ ...active.cwd ? { cwd: active.cwd } : {},
1816
+ startedAt: active.startedAt
1817
+ }));
1818
+ }
1819
+ function sendActiveProcessReport(ws) {
1820
+ sendWorkerMessage(ws, {
1821
+ type: "active_processes",
1822
+ processes: buildActiveProcessReports()
1823
+ });
1695
1824
  }
1696
1825
  const PTY_BRIDGE_SCRIPT = String.raw`
1697
1826
  const readline = require("node:readline");
@@ -1875,7 +2004,7 @@ function resolveHostShell() {
1875
2004
  }
1876
2005
  return { file: process.env.SHELL || "/bin/bash", args: ["-l"] };
1877
2006
  }
1878
- function openPty(input) {
2007
+ async function openPty(input) {
1879
2008
  try {
1880
2009
  const branchPath = ensureBranchWorkspace({
1881
2010
  projectId: input.projectId,
@@ -1886,6 +2015,14 @@ function openPty(input) {
1886
2015
  branchName: input.message.branchName,
1887
2016
  manifest: input.manifest
1888
2017
  });
2018
+ const planProcessEnv = await preparePlanEnvForShell({
2019
+ baseUrl: input.baseUrl,
2020
+ token: input.token,
2021
+ projectId: input.projectId,
2022
+ branchName: input.message.branchName,
2023
+ planRoot: input.planRoot,
2024
+ activePlanId: input.message.env?.R5D_ACTIVE_PLAN_ID
2025
+ });
1889
2026
  const shell = resolveHostShell();
1890
2027
  const ptyProcess = createNodePtyBridge({
1891
2028
  file: shell.file,
@@ -1899,6 +2036,7 @@ function openPty(input) {
1899
2036
  ...process.env,
1900
2037
  ...containerRegistryEnv(),
1901
2038
  ...input.message.env ?? {},
2039
+ ...planProcessEnv,
1902
2040
  R5D_PROJECT_ID: input.projectId,
1903
2041
  R5D_BRANCH_NAME: input.message.branchName
1904
2042
  }
@@ -1999,6 +2137,7 @@ async function startWorker(options) {
1999
2137
  const projectsRoot = import_node_path.default.resolve(options.projectRoot ?? defaultProjectsRoot());
2000
2138
  const syncRoot = import_node_path.default.resolve(options.syncRoot ?? process.env.R5D_SYNC_ROOT ?? defaultSyncRoot());
2001
2139
  const artifactRoot = import_node_path.default.resolve(options.artifactRoot ?? process.env.R5D_ARTIFACTS_ROOT ?? defaultArtifactRoot());
2140
+ const planRoot = import_node_path.default.resolve(options.planRoot ?? process.env.R5D_PLANS_ROOT ?? defaultPlanRoot());
2002
2141
  process.stdout.write(`[r5d-worker] label: ${label}
2003
2142
  `);
2004
2143
  process.stdout.write(`[r5d-worker] projects root: ${projectsRoot}
@@ -2006,6 +2145,8 @@ async function startWorker(options) {
2006
2145
  process.stdout.write(`[r5d-worker] sync root: ${syncRoot}
2007
2146
  `);
2008
2147
  process.stdout.write(`[r5d-worker] artifact root: ${artifactRoot}
2148
+ `);
2149
+ process.stdout.write(`[r5d-worker] plan root: ${planRoot}
2009
2150
  `);
2010
2151
  process.stdout.write(`[r5d-worker] server: ${baseUrl}
2011
2152
  `);
@@ -2014,6 +2155,7 @@ async function startWorker(options) {
2014
2155
  import_node_fs.default.mkdirSync(projectsRoot, { recursive: true });
2015
2156
  import_node_fs.default.mkdirSync(syncRoot, { recursive: true });
2016
2157
  import_node_fs.default.mkdirSync(artifactRoot, { recursive: true });
2158
+ import_node_fs.default.mkdirSync(planRoot, { recursive: true });
2017
2159
  const manifestByProjectId = /* @__PURE__ */ new Map();
2018
2160
  const ws = new WebSocket(websocketUrl(baseUrl, label), {
2019
2161
  headers: {
@@ -2021,6 +2163,7 @@ async function startWorker(options) {
2021
2163
  }
2022
2164
  });
2023
2165
  ws.addEventListener("open", () => {
2166
+ currentWorkerSocket = ws;
2024
2167
  const hello = {
2025
2168
  type: "hello",
2026
2169
  hostInfo: {
@@ -2030,10 +2173,12 @@ async function startWorker(options) {
2030
2173
  pid: process.pid,
2031
2174
  version: getWorkerVersion(),
2032
2175
  projectRoot: projectsRoot,
2033
- artifactRoot
2176
+ artifactRoot,
2177
+ planRoot
2034
2178
  }
2035
2179
  };
2036
2180
  ws.send(JSON.stringify(hello));
2181
+ sendActiveProcessReport(ws);
2037
2182
  process.stdout.write("[r5d-worker] connected\n");
2038
2183
  });
2039
2184
  ws.addEventListener("message", (event) => {
@@ -2056,17 +2201,29 @@ async function startWorker(options) {
2056
2201
  const branches = project.branches.length > 0 ? project.branches : [project.defaultBranch || "main"];
2057
2202
  for (const branchName of branches) {
2058
2203
  try {
2059
- await withBranchLock(project.projectId, branchName, async () => {
2060
- ensureBranchWorkspace({
2061
- projectId: project.projectId,
2204
+ ensureBranchWorkspace({
2205
+ projectId: project.projectId,
2206
+ baseUrl,
2207
+ token,
2208
+ projectRoot,
2209
+ syncRoot,
2210
+ branchName,
2211
+ manifest: project
2212
+ });
2213
+ try {
2214
+ await syncProjectPlans({
2062
2215
  baseUrl,
2063
2216
  token,
2064
- projectRoot,
2065
- syncRoot,
2217
+ projectId: project.projectId,
2066
2218
  branchName,
2067
- manifest: project
2219
+ planRoot
2068
2220
  });
2069
- });
2221
+ } catch (error) {
2222
+ process.stderr.write(
2223
+ `[r5d-worker] plan sync skipped for ${project.projectId}/${branchName}: ${error instanceof Error ? error.message : String(error)}
2224
+ `
2225
+ );
2226
+ }
2070
2227
  } catch (error) {
2071
2228
  process.stderr.write(
2072
2229
  `[r5d-worker] failed to sync ${project.repoSlug}/${branchName}: ${error instanceof Error ? error.message : String(error)}
@@ -2094,7 +2251,7 @@ async function startWorker(options) {
2094
2251
  requestId: message.requestId,
2095
2252
  runId: message.runId,
2096
2253
  cancelled: true,
2097
- message: active ? `Sent SIGTERM to ${active.command.join(" ")}` : "Cancellation queued before command start"
2254
+ message: active ? `Sent SIGTERM to ${active.command}` : "Cancellation queued before command start"
2098
2255
  })
2099
2256
  );
2100
2257
  return;
@@ -2104,7 +2261,7 @@ async function startWorker(options) {
2104
2261
  const projectRoot = projectRootFor(projectsRoot, message.projectId, manifestByProjectId);
2105
2262
  process.stdout.write(`[r5d-worker] pty ${message.ptyId}: ${message.projectId}/${message.branchName}
2106
2263
  `);
2107
- openPty({ ws, message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, manifest });
2264
+ await openPty({ ws, message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, planRoot, manifest });
2108
2265
  return;
2109
2266
  }
2110
2267
  if (message.type === "pty_input") {
@@ -2124,11 +2281,17 @@ async function startWorker(options) {
2124
2281
  const projectRoot = projectRootFor(projectsRoot, message.projectId, manifestByProjectId);
2125
2282
  process.stdout.write(`[r5d-worker] exec ${message.runId}: ${message.argv.join(" ")}
2126
2283
  `);
2127
- const result = await withBranchLock(
2128
- message.projectId,
2129
- message.branchName,
2130
- () => executeCommand({ message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, artifactRoot, manifest })
2131
- );
2284
+ const result = await executeCommand({
2285
+ message,
2286
+ projectId: message.projectId,
2287
+ baseUrl,
2288
+ token,
2289
+ projectRoot,
2290
+ syncRoot,
2291
+ artifactRoot,
2292
+ planRoot,
2293
+ manifest
2294
+ });
2132
2295
  ws.send(JSON.stringify(result));
2133
2296
  return;
2134
2297
  }
@@ -2143,11 +2306,18 @@ async function startWorker(options) {
2143
2306
  const projectRoot = projectRootFor(projectsRoot, message.projectId, manifestByProjectId);
2144
2307
  process.stdout.write(`[r5d-worker] exec_start ${message.runId}: ${message.argv.join(" ")}
2145
2308
  `);
2146
- void withBranchLock(
2147
- message.projectId,
2148
- message.branchName,
2149
- () => executeStreamingCommand({ ws, message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, artifactRoot, manifest })
2150
- ).catch((error) => {
2309
+ void executeStreamingCommand({
2310
+ ws,
2311
+ message,
2312
+ projectId: message.projectId,
2313
+ baseUrl,
2314
+ token,
2315
+ projectRoot,
2316
+ syncRoot,
2317
+ artifactRoot,
2318
+ planRoot,
2319
+ manifest
2320
+ }).catch((error) => {
2151
2321
  sendWorkerMessage(ws, {
2152
2322
  type: "exec_start_error",
2153
2323
  requestId: message.requestId,
@@ -2169,11 +2339,16 @@ async function startWorker(options) {
2169
2339
  try {
2170
2340
  const manifest = manifestByProjectId.get(message.projectId);
2171
2341
  const projectRoot = projectRootFor(projectsRoot, message.projectId, manifestByProjectId);
2172
- const result = await withBranchLock(
2173
- message.projectId,
2174
- message.branchName,
2175
- () => executeOperation({ message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, artifactRoot, manifest })
2176
- );
2342
+ const result = await executeOperation({
2343
+ message,
2344
+ projectId: message.projectId,
2345
+ baseUrl,
2346
+ token,
2347
+ projectRoot,
2348
+ syncRoot,
2349
+ artifactRoot,
2350
+ manifest
2351
+ });
2177
2352
  ws.send(
2178
2353
  JSON.stringify({
2179
2354
  type: "operation_result",
@@ -2208,10 +2383,31 @@ async function startWorker(options) {
2208
2383
  });
2209
2384
  });
2210
2385
  ws.addEventListener("close", (event) => {
2386
+ if (currentWorkerSocket === ws) {
2387
+ currentWorkerSocket = null;
2388
+ }
2211
2389
  closeAllPtys();
2212
2390
  const reason = event.reason ? `: ${event.reason}` : "";
2213
2391
  process.stderr.write(`[r5d-worker] disconnected (${event.code}${reason})
2214
2392
  `);
2393
+ if (activeProcesses.size > 0) {
2394
+ const delayMs = 2e3;
2395
+ process.stderr.write(`[r5d-worker] ${activeProcesses.size} process(es) still active; reconnecting in ${delayMs}ms
2396
+ `);
2397
+ const reconnect = () => {
2398
+ void startWorker(options).catch((error) => {
2399
+ process.stderr.write(`[r5d-worker] reconnect failed: ${error instanceof Error ? error.message : String(error)}
2400
+ `);
2401
+ if (activeProcesses.size > 0) {
2402
+ setTimeout(reconnect, delayMs);
2403
+ return;
2404
+ }
2405
+ process.exit(1);
2406
+ });
2407
+ };
2408
+ setTimeout(reconnect, delayMs);
2409
+ return;
2410
+ }
2215
2411
  process.exit(event.code === 1e3 ? 0 : 1);
2216
2412
  });
2217
2413
  ws.addEventListener("error", (event) => {
@@ -2252,6 +2448,8 @@ if (isCliEntrypoint()) {
2252
2448
  ensureVisibleGitCheckout,
2253
2449
  isArtifactEnvPath,
2254
2450
  prepareArtifactEnvForShell,
2451
+ preparePlanEnvForShell,
2255
2452
  resolveProjectFilePath,
2453
+ syncProjectPlans,
2256
2454
  syncSessionArtifacts
2257
2455
  });