@ricsam/r5d-worker 0.0.90 → 0.0.92

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
@@ -49,16 +49,15 @@ import {
49
49
  isRetryableWorkerServerStatus,
50
50
  superviseWorkerRuntime,
51
51
  WORKER_CREDENTIAL_RESTART_EXIT_CODE,
52
- WORKER_RECONNECT_DELAY_MS,
53
52
  WORKER_RECONNECT_EXIT_CODE,
54
53
  WORKER_RELOAD_EXIT_CODE,
55
- WORKER_RUNTIME_ENV
54
+ WORKER_RUNTIME_ENV,
55
+ workerReconnectDelayMs
56
56
  } from "./supervisor.mjs";
57
57
  import { assertDisjointManagedRoots, managedProjectRoot, validateManagedBranchName } from "./managed-paths.mjs";
58
58
  import {
59
59
  applyObservedProjectMirrorHeads,
60
60
  assertProjectMirrorHeadsPushed,
61
- recoverStaleProjectWorktreeSnapshots,
62
61
  createOrRetryLinkedProjectBranch,
63
62
  deleteLinkedProjectBranch,
64
63
  deleteProjectMirrorBranch,
@@ -71,6 +70,11 @@ import {
71
70
  pushProjectMirrorHeads,
72
71
  removeProjectWorktrees
73
72
  } from "./project-worktrees.mjs";
73
+ import {
74
+ PROJECT_SNAPSHOT_RECOVERY_HELPER_COMMAND,
75
+ recoverProjectSnapshotsInChild,
76
+ runProjectSnapshotRecoveryHelper
77
+ } from "./project-snapshot-recovery-runner.mjs";
74
78
  import {
75
79
  ProjectWorkspacePendingBranchPathChangeError,
76
80
  ProjectWorkspaceStateStore,
@@ -87,6 +91,21 @@ import {
87
91
  workspaceGitMountsForRemediation,
88
92
  WorkspaceRemediationAncestryError
89
93
  } from "./workspace-git-sync.mjs";
94
+ const WORKER_LIFECYCLE_DETAIL_MAX_LENGTH = 500;
95
+ const WORKER_SERVER_HEARTBEAT_INTERVAL_MS = 15e3;
96
+ const WORKER_SERVER_HEARTBEAT_TIMEOUT_MS = 2 * 60 * 1e3;
97
+ function boundedWorkerLifecycleProgress(progress) {
98
+ return progress.detail === void 0 ? progress : { ...progress, detail: progress.detail.slice(0, WORKER_LIFECYCLE_DETAIL_MAX_LENGTH) };
99
+ }
100
+ function workerServerHeartbeatAction(input) {
101
+ if (input.elapsedMs < input.timeoutMs) return "wait";
102
+ if (input.watchdogSweepDelayed || input.probeElapsedMs === void 0) return "start_probe";
103
+ return input.probeElapsedMs >= input.timeoutMs ? "terminate" : "wait";
104
+ }
105
+ const workerLifecycleTestHarness = {
106
+ boundedWorkerLifecycleProgress,
107
+ workerServerHeartbeatAction
108
+ };
90
109
  class ProjectWorkspaceConfigurationDeferredError extends Error {
91
110
  }
92
111
  class WorkerServerUnavailableError extends Error {
@@ -2600,6 +2619,13 @@ async function executeCommand(input) {
2600
2619
  const startedAt = Date.now();
2601
2620
  let timeout;
2602
2621
  let spawnedProcess;
2622
+ let preparationSettled = false;
2623
+ const settlePreparation = () => {
2624
+ if (preparationSettled) return;
2625
+ preparationSettled = true;
2626
+ input.setPreparationHeartbeatLease(false);
2627
+ };
2628
+ input.setPreparationHeartbeatLease(true);
2603
2629
  try {
2604
2630
  const targetProcessEnv = await prepareShellEnvForTarget({
2605
2631
  target: input.resolvedTarget.target,
@@ -2637,6 +2663,7 @@ async function executeCommand(input) {
2637
2663
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
2638
2664
  ...input.message.workspaceEffect === "none" ? { workspaceEffect: "none" } : {}
2639
2665
  });
2666
+ settlePreparation();
2640
2667
  if (input.message.timeoutMs) {
2641
2668
  timeout = setTimeout(() => {
2642
2669
  void terminateProcessTree(subprocess).catch((error) => {
@@ -2672,6 +2699,7 @@ async function executeCommand(input) {
2672
2699
  error: error instanceof Error ? error.message : String(error)
2673
2700
  };
2674
2701
  } finally {
2702
+ settlePreparation();
2675
2703
  if (timeout) {
2676
2704
  clearTimeout(timeout);
2677
2705
  }
@@ -2687,6 +2715,13 @@ async function executeStreamingCommand(input) {
2687
2715
  let timedOut = false;
2688
2716
  let timeout;
2689
2717
  let spawnedProcess;
2718
+ let preparationSettled = false;
2719
+ const settlePreparation = () => {
2720
+ if (preparationSettled) return;
2721
+ preparationSettled = true;
2722
+ input.setPreparationHeartbeatLease(false);
2723
+ };
2724
+ input.setPreparationHeartbeatLease(true);
2690
2725
  try {
2691
2726
  if (cancelledProcessRuns.delete(input.message.runId)) {
2692
2727
  sendWorkerMessage(input.ws, {
@@ -2739,6 +2774,7 @@ async function executeStreamingCommand(input) {
2739
2774
  ...interactive ? { interactive: true, stdin: subprocess.stdin } : {},
2740
2775
  ...input.message.workspaceEffect === "none" ? { workspaceEffect: "none" } : {}
2741
2776
  });
2777
+ settlePreparation();
2742
2778
  started = true;
2743
2779
  sendWorkerMessage(input.ws, {
2744
2780
  type: "exec_started",
@@ -2815,6 +2851,7 @@ async function executeStreamingCommand(input) {
2815
2851
  });
2816
2852
  }
2817
2853
  } finally {
2854
+ settlePreparation();
2818
2855
  if (timeout) {
2819
2856
  clearTimeout(timeout);
2820
2857
  }
@@ -3156,7 +3193,8 @@ function createNodePtyBridge(options) {
3156
3193
  };
3157
3194
  }
3158
3195
  const workerPtyBridgeTestHarness = {
3159
- create: createNodePtyBridge
3196
+ create: createNodePtyBridge,
3197
+ closeAllBestEffort: closePtysBestEffort
3160
3198
  };
3161
3199
  function resolveHostShell(command, platform = process.platform) {
3162
3200
  if (platform === "win32") {
@@ -3305,11 +3343,23 @@ function closePty(message) {
3305
3343
  }
3306
3344
  ptyProcess.kill();
3307
3345
  }
3308
- function closeAllPtys() {
3309
- for (const ptyProcess of activePtys.values()) {
3310
- ptyProcess.kill();
3346
+ function closePtysBestEffort(ptys, reportFailure) {
3347
+ for (const [ptyId, ptyProcess] of ptys) {
3348
+ try {
3349
+ ptyProcess.kill();
3350
+ } catch (error) {
3351
+ reportFailure(ptyId, error);
3352
+ }
3311
3353
  }
3312
3354
  }
3355
+ function closeAllPtys() {
3356
+ closePtysBestEffort(activePtys, (ptyId, error) => {
3357
+ process.stderr.write(
3358
+ `[r5d-worker] failed to close PTY ${ptyId} after control disconnect: ${error instanceof Error ? error.message : String(error)}
3359
+ `
3360
+ );
3361
+ });
3362
+ }
3313
3363
  async function terminateCredentialBearingChildren(children) {
3314
3364
  const results = await Promise.allSettled(children.map(async ({ terminate }) => await terminate()));
3315
3365
  const failures = results.flatMap(
@@ -3369,16 +3419,6 @@ function websocketUrl(baseUrl, label) {
3369
3419
  }
3370
3420
  async function startWorker(options) {
3371
3421
  advanceWorkerAdmissionGeneration();
3372
- process.on("uncaughtException", (error) => {
3373
- process.stderr.write(`[r5d-worker] uncaught exception: ${error instanceof Error ? error.stack ?? error.message : String(error)}
3374
- `);
3375
- });
3376
- process.on("unhandledRejection", (reason) => {
3377
- process.stderr.write(
3378
- `[r5d-worker] unhandled rejection: ${reason instanceof Error ? reason.stack ?? reason.message : String(reason)}
3379
- `
3380
- );
3381
- });
3382
3422
  const config = readConfig(options.configPath);
3383
3423
  const { baseUrl, token } = resolveCredentials(options, config);
3384
3424
  initializeCredentialStoreDirectoryOnce();
@@ -3411,28 +3451,6 @@ async function startWorker(options) {
3411
3451
  runGit(["--version"]);
3412
3452
  await verifyR5dctlAuth(baseUrl, token, label);
3413
3453
  const initializedWorkspaceState = await workspaceSyncSingleFlight.runExclusive(() => {
3414
- if (!startupProjectSnapshotRecoveryCompleted) {
3415
- const snapshotRecovery = recoverStaleProjectWorktreeSnapshots({
3416
- projectsRoot,
3417
- temporaryRoot: projectWorktreeSnapshotsRoot
3418
- });
3419
- if (snapshotRecovery.restored.length > 0) {
3420
- process.stdout.write(`[r5d-worker] restored ${snapshotRecovery.restored.length} interrupted project snapshot(s)
3421
- `);
3422
- }
3423
- if (snapshotRecovery.removed.length > 0) {
3424
- process.stdout.write(`[r5d-worker] removed ${snapshotRecovery.removed.length} completed/incomplete project snapshot(s)
3425
- `);
3426
- }
3427
- for (const failure of snapshotRecovery.failed) {
3428
- process.stderr.write(`[r5d-worker] failed to recover stale project snapshot ${failure.path}: ${failure.error}
3429
- `);
3430
- }
3431
- if (snapshotRecovery.failed.length > 0) {
3432
- throw new Error("Project snapshot recovery is incomplete; refusing to mutate project checkouts");
3433
- }
3434
- startupProjectSnapshotRecoveryCompleted = true;
3435
- }
3436
3454
  fs.mkdirSync(projectsRoot, { recursive: true });
3437
3455
  fs.mkdirSync(syncRoot, { recursive: true });
3438
3456
  fs.mkdirSync(artifactRoot, { recursive: true });
@@ -3471,6 +3489,8 @@ async function startWorker(options) {
3471
3489
  let cliUpdateInProgress = false;
3472
3490
  let reloadAfterClose = false;
3473
3491
  let shutdownAfterClose = false;
3492
+ let startupRecoveryComplete = startupProjectSnapshotRecoveryCompleted;
3493
+ let startupRecoveryPromise = Promise.resolve();
3474
3494
  const automaticWorkspaceSyncIsCurrentlyAdmitted = () => workspaceAutomaticSyncIsAdmitted({
3475
3495
  workspaceConfigured,
3476
3496
  activeIncidentId: activeWorkspaceIncidentId,
@@ -3887,8 +3907,14 @@ async function startWorker(options) {
3887
3907
  })
3888
3908
  )
3889
3909
  ].sort();
3890
- const ensureConfiguredProjects = async () => {
3891
- for (const project of projectConfigById.values()) {
3910
+ const ensureConfiguredProjects = async (onProgress) => {
3911
+ const projects = [...projectConfigById.values()];
3912
+ for (const [projectIndex, project] of projects.entries()) {
3913
+ onProgress?.({
3914
+ detail: `Configuring ${project.projectPath}`,
3915
+ completedItems: projectIndex,
3916
+ totalItems: projects.length
3917
+ });
3892
3918
  validateProjectId(project.projectId);
3893
3919
  assertRepositoryTransitionState(project);
3894
3920
  for (const branch of project.branches) validateBranchName(branch.branchName);
@@ -3936,7 +3962,14 @@ async function startWorker(options) {
3936
3962
  mirrorUrl: connection.mirrorUrl,
3937
3963
  mirrorCredentialUsername: connection.mirrorCredentialUsername,
3938
3964
  credentialHelper: connection.credentialHelper,
3939
- gitIdentity: workspaceGitIdentity
3965
+ gitIdentity: workspaceGitIdentity,
3966
+ onSnapshotProgress: (progress) => onProgress?.({
3967
+ detail: progress.phase === "scanning" ? `Measuring ${project.projectPath} workspace snapshot` : progress.phase === "syncing" ? `Making ${project.projectPath} workspace snapshot durable` : `${progress.operation === "restore" ? "Restoring" : "Snapshotting"} ${project.projectPath}${progress.branchName ? `/${progress.branchName}` : ""}`,
3968
+ completedBytes: progress.completedBytes,
3969
+ completedItems: progress.completedBranches,
3970
+ totalItems: progress.totalBranches,
3971
+ ...progress.totalBytes === void 0 ? {} : { totalBytes: progress.totalBytes }
3972
+ })
3940
3973
  });
3941
3974
  readyProjectIds.add(project.projectId);
3942
3975
  reconciledProjectConfigFingerprints.set(project.projectId, configFingerprint);
@@ -3971,6 +4004,11 @@ async function startWorker(options) {
3971
4004
  `
3972
4005
  );
3973
4006
  }
4007
+ onProgress?.({
4008
+ detail: `Configured ${project.projectPath}`,
4009
+ completedItems: projectIndex + 1,
4010
+ totalItems: projects.length
4011
+ });
3974
4012
  }
3975
4013
  };
3976
4014
  const collectAheadOfOriginBranches = async () => {
@@ -4363,6 +4401,11 @@ async function startWorker(options) {
4363
4401
  if (input.sendResult !== false) sendWorkspaceSyncResult(input.requestId, result2);
4364
4402
  return result2;
4365
4403
  }
4404
+ sendLifecycleProgress({
4405
+ phase: "syncing",
4406
+ operationId: attemptId,
4407
+ detail: input.trigger.detail ?? `Synchronizing workspace (${input.trigger.type})`
4408
+ });
4366
4409
  if (!input.requestId && input.sendResult !== false) {
4367
4410
  if (currentWorkerSocket !== ws || ws.readyState !== WebSocket.OPEN) {
4368
4411
  throw new Error("Cannot start autonomous workspace synchronization without an open worker control socket");
@@ -4465,6 +4508,9 @@ async function startWorker(options) {
4465
4508
  }
4466
4509
  };
4467
4510
  if (input.sendResult !== false) sendWorkspaceSyncResult(input.requestId, result);
4511
+ sendLifecycleProgress(
4512
+ result.outcome === "failed" ? { phase: "failed", operationId: attemptId, detail: result.error ?? "Workspace synchronization failed" } : { phase: "ready", operationId: attemptId, detail: "Workspace synchronized" }
4513
+ );
4468
4514
  return result;
4469
4515
  };
4470
4516
  const scheduleAutomaticWorkspaceSync = (trigger, delayMs = WORKSPACE_GIT_QUIET_MS) => {
@@ -4539,6 +4585,14 @@ async function startWorker(options) {
4539
4585
  });
4540
4586
  };
4541
4587
  const configureWorkerWorkspace = async (message, receiptGeneration) => {
4588
+ const configurationOperationId = crypto.randomUUID();
4589
+ sendLifecycleProgress({
4590
+ phase: "configuring",
4591
+ operationId: configurationOperationId,
4592
+ detail: `Preparing ${message.projects.length} project(s)`,
4593
+ completedItems: 0,
4594
+ totalItems: message.projects.length
4595
+ });
4542
4596
  const incidentDeferral = workspaceConfigurationIncidentDeferral({
4543
4597
  requestedIncidentId: message.deferWorkspaceSyncForIncidentId,
4544
4598
  activeIncidentId: activeWorkspaceIncidentId
@@ -4903,7 +4957,13 @@ async function startWorker(options) {
4903
4957
  // checkout initialization from erasing synchronized dirt.
4904
4958
  { ignoreBusy: true }
4905
4959
  );
4906
- await ensureConfiguredProjects();
4960
+ await ensureConfiguredProjects((progress) => {
4961
+ sendLifecycleProgress({ phase: "configuring", operationId: configurationOperationId, ...progress });
4962
+ process.stdout.write(
4963
+ `[r5d-worker] ${progress.detail ?? "Configuring workspace"}${progress.completedBytes === void 0 ? "" : `: ${progress.completedBytes}/${progress.totalBytes ?? "?"} bytes`}${progress.completedItems === void 0 ? "" : `, ${progress.completedItems}/${progress.totalItems ?? "?"} item(s)`}
4964
+ `
4965
+ );
4966
+ });
4907
4967
  const configurationSyncAdmissionGeneration = workerAdmissionGeneration;
4908
4968
  const assertConfigurationSyncStillAdmitted = () => {
4909
4969
  if (receiptGeneration !== workspaceConfigurationReceiptGeneration || configurationSyncAdmissionGeneration !== workerAdmissionGeneration || !workspaceConfigurationIncidentSnapshotIsCurrent(incidentDeferral.incidentId, activeWorkspaceIncidentId) || currentWorkerSocket !== ws || ws.readyState !== WebSocket.OPEN) {
@@ -5002,6 +5062,73 @@ async function startWorker(options) {
5002
5062
  Authorization: `Bearer ${token}`
5003
5063
  }
5004
5064
  });
5065
+ let lastServerControlMessageAtMs = performance.now();
5066
+ let serverHeartbeatProbeStartedAtMs;
5067
+ let lastServerHeartbeatSweepAtMs = performance.now();
5068
+ let serverHeartbeatWatchdog;
5069
+ const noteServerControlActivity = () => {
5070
+ lastServerControlMessageAtMs = performance.now();
5071
+ serverHeartbeatProbeStartedAtMs = void 0;
5072
+ };
5073
+ const stopServerHeartbeatWatchdog = () => {
5074
+ if (!serverHeartbeatWatchdog) return;
5075
+ clearInterval(serverHeartbeatWatchdog);
5076
+ serverHeartbeatWatchdog = void 0;
5077
+ };
5078
+ const sendLifecycleProgress = (progress) => {
5079
+ if (currentWorkerSocket !== ws || ws.readyState !== WebSocket.OPEN) return;
5080
+ sendWorkerMessage(ws, { type: "worker_progress", progress: boundedWorkerLifecycleProgress(progress) });
5081
+ };
5082
+ const describeSnapshotProgress = (progress) => {
5083
+ const project = path.relative(projectsRoot, progress.projectRoot) || path.basename(progress.projectRoot);
5084
+ const branch = progress.branchName ? `/${progress.branchName}` : "";
5085
+ if (progress.phase === "scanning") return `Scanning ${project} recovery snapshot`;
5086
+ if (progress.phase === "syncing") return `Making restored ${project} files durable`;
5087
+ if (progress.phase === "cleaning") return `Cleaning recovered ${project} snapshot`;
5088
+ return `Restoring ${project}${branch}`;
5089
+ };
5090
+ const performStartupRecovery = async (operationId) => {
5091
+ if (startupProjectSnapshotRecoveryCompleted) return;
5092
+ sendLifecycleProgress({ phase: "recovering", operationId, detail: "Checking interrupted workspace transactions" });
5093
+ const snapshotRecovery = await workspaceSyncSingleFlight.runExclusive(
5094
+ async () => await recoverProjectSnapshotsInChild({
5095
+ projectsRoot,
5096
+ snapshotRoot: projectWorktreeSnapshotsRoot,
5097
+ onProgress: (progress) => {
5098
+ const lifecycleProgress = {
5099
+ phase: "recovering",
5100
+ operationId,
5101
+ detail: describeSnapshotProgress(progress),
5102
+ completedBytes: progress.completedBytes,
5103
+ completedItems: progress.completedBranches,
5104
+ totalItems: progress.totalBranches,
5105
+ ...progress.totalBytes === void 0 ? {} : { totalBytes: progress.totalBytes }
5106
+ };
5107
+ sendLifecycleProgress(lifecycleProgress);
5108
+ process.stdout.write(
5109
+ `[r5d-worker] ${lifecycleProgress.detail}: ${progress.completedBranches}/${progress.totalBranches} branch(es), ${progress.completedBytes}/${progress.totalBytes ?? "?"} bytes
5110
+ `
5111
+ );
5112
+ }
5113
+ })
5114
+ );
5115
+ if (snapshotRecovery.restored.length > 0) {
5116
+ process.stdout.write(`[r5d-worker] restored ${snapshotRecovery.restored.length} interrupted project snapshot(s)
5117
+ `);
5118
+ }
5119
+ if (snapshotRecovery.removed.length > 0) {
5120
+ process.stdout.write(`[r5d-worker] removed ${snapshotRecovery.removed.length} completed/incomplete project snapshot(s)
5121
+ `);
5122
+ }
5123
+ for (const failure of snapshotRecovery.failed) {
5124
+ process.stderr.write(`[r5d-worker] failed to recover stale project snapshot ${failure.path}: ${failure.error}
5125
+ `);
5126
+ }
5127
+ if (snapshotRecovery.failed.length > 0) {
5128
+ throw new Error("Project snapshot recovery is incomplete; refusing to mutate project checkouts");
5129
+ }
5130
+ startupProjectSnapshotRecoveryCompleted = true;
5131
+ };
5005
5132
  const handleGracefulShutdown = () => {
5006
5133
  if (shutdownAfterClose) return;
5007
5134
  shutdownAfterClose = true;
@@ -5031,8 +5158,50 @@ async function startWorker(options) {
5031
5158
  process.on("SIGINT", handleGracefulShutdown);
5032
5159
  ws.addEventListener("open", () => {
5033
5160
  currentWorkerSocket = ws;
5161
+ noteServerControlActivity();
5162
+ lastServerHeartbeatSweepAtMs = performance.now();
5163
+ serverHeartbeatWatchdog = setInterval(() => {
5164
+ if (currentWorkerSocket !== ws || ws.readyState !== WebSocket.OPEN) return;
5165
+ const nowMs = performance.now();
5166
+ const heartbeatAction = workerServerHeartbeatAction({
5167
+ elapsedMs: Math.max(0, nowMs - lastServerControlMessageAtMs),
5168
+ ...serverHeartbeatProbeStartedAtMs === void 0 ? {} : { probeElapsedMs: Math.max(0, nowMs - serverHeartbeatProbeStartedAtMs) },
5169
+ timeoutMs: WORKER_SERVER_HEARTBEAT_TIMEOUT_MS,
5170
+ watchdogSweepDelayed: nowMs - lastServerHeartbeatSweepAtMs >= WORKER_SERVER_HEARTBEAT_INTERVAL_MS * 2
5171
+ });
5172
+ lastServerHeartbeatSweepAtMs = nowMs;
5173
+ if (heartbeatAction === "start_probe") {
5174
+ serverHeartbeatProbeStartedAtMs = nowMs;
5175
+ return;
5176
+ }
5177
+ if (heartbeatAction === "terminate") {
5178
+ stopServerHeartbeatWatchdog();
5179
+ process.stderr.write("[r5d-worker] server control heartbeat timed out; reconnecting\n");
5180
+ ws.terminate();
5181
+ }
5182
+ }, WORKER_SERVER_HEARTBEAT_INTERVAL_MS);
5183
+ serverHeartbeatWatchdog.unref();
5184
+ const startupOperationId = crypto.randomUUID();
5185
+ startupRecoveryPromise = Promise.resolve().then(async () => await performStartupRecovery(startupOperationId)).then(() => {
5186
+ startupRecoveryComplete = true;
5187
+ if (currentWorkerSocket === ws && ws.readyState === WebSocket.OPEN) {
5188
+ sendWorkerMessage(ws, { type: "startup_ready" });
5189
+ sendLifecycleProgress({
5190
+ phase: "configuring",
5191
+ operationId: crypto.randomUUID(),
5192
+ detail: "Waiting for workspace configuration"
5193
+ });
5194
+ }
5195
+ }).catch((error) => {
5196
+ const message = error instanceof Error ? error.message : String(error);
5197
+ sendLifecycleProgress({ phase: "failed", operationId: startupOperationId, detail: message });
5198
+ process.stderr.write(`[r5d-worker] startup recovery failed: ${message}
5199
+ `);
5200
+ ws.close(1011, "Project snapshot recovery failed");
5201
+ });
5034
5202
  const hello = {
5035
5203
  type: "hello",
5204
+ startupReady: startupRecoveryComplete,
5036
5205
  hostInfo: {
5037
5206
  hostname: hostname(),
5038
5207
  platform: process.platform,
@@ -5068,8 +5237,11 @@ async function startWorker(options) {
5068
5237
  process.stdout.write("[r5d-worker] connected\n");
5069
5238
  });
5070
5239
  ws.addEventListener("message", (event) => {
5240
+ noteServerControlActivity();
5071
5241
  void (async () => {
5072
5242
  const message = JSON.parse(String(event.data));
5243
+ if (!startupRecoveryComplete && message.type !== "ping") await startupRecoveryPromise;
5244
+ if (!startupRecoveryComplete && message.type !== "ping") return;
5073
5245
  const messageAdmissionGeneration = workerAdmissionGeneration;
5074
5246
  const assertMessageAdmission = () => assertWorkerChildAdmission({
5075
5247
  capturedGeneration: messageAdmissionGeneration,
@@ -5097,6 +5269,19 @@ async function startWorker(options) {
5097
5269
  aheadOfOriginBranches: configured.aheadOfOriginBranches,
5098
5270
  ...configured.deferredWorkspaceSyncForIncidentId ? { deferredWorkspaceSyncForIncidentId: configured.deferredWorkspaceSyncForIncidentId } : {}
5099
5271
  });
5272
+ sendLifecycleProgress(
5273
+ configured.result.outcome === "failed" ? {
5274
+ phase: "failed",
5275
+ operationId: configured.result.attemptId,
5276
+ detail: configured.result.error ?? "Workspace configuration failed"
5277
+ } : {
5278
+ phase: "ready",
5279
+ operationId: configured.result.attemptId,
5280
+ detail: `${message.projects.length} project(s) configured`,
5281
+ completedItems: message.projects.length,
5282
+ totalItems: message.projects.length
5283
+ }
5284
+ );
5100
5285
  process.stdout.write(
5101
5286
  `[r5d-worker] workspace configured: ${message.projects.length} project(s), ${configured.pending.length} pending checkout(s)
5102
5287
  `
@@ -5660,6 +5845,13 @@ async function startWorker(options) {
5660
5845
  token,
5661
5846
  artifactRoot,
5662
5847
  planRoot,
5848
+ setPreparationHeartbeatLease: (active) => {
5849
+ sendWorkerMessageFromCurrentSource(ws, {
5850
+ type: "heartbeat_lease",
5851
+ operationId: `exec:${message.runId}`,
5852
+ active
5853
+ });
5854
+ },
5663
5855
  assertAdmission: () => {
5664
5856
  if (!workspaceCommandTransportIsAllowed(deferredWorkspaceConfiguration, activeWorkspaceIncidentId, "exec")) {
5665
5857
  throw new Error(
@@ -5723,6 +5915,13 @@ async function startWorker(options) {
5723
5915
  token,
5724
5916
  artifactRoot,
5725
5917
  planRoot,
5918
+ setPreparationHeartbeatLease: (active) => {
5919
+ sendWorkerMessageFromCurrentSource(ws, {
5920
+ type: "heartbeat_lease",
5921
+ operationId: `exec:${message.runId}`,
5922
+ active
5923
+ });
5924
+ },
5726
5925
  assertAdmission: assertMessageAdmission
5727
5926
  });
5728
5927
  } finally {
@@ -5855,6 +6054,7 @@ async function startWorker(options) {
5855
6054
  const processEvents = process;
5856
6055
  processEvents.off("SIGTERM", handleGracefulShutdown);
5857
6056
  processEvents.off("SIGINT", handleGracefulShutdown);
6057
+ stopServerHeartbeatWatchdog();
5858
6058
  if (workspaceAutomaticTimer) {
5859
6059
  clearTimeout(workspaceAutomaticTimer);
5860
6060
  workspaceAutomaticTimer = void 0;
@@ -5885,23 +6085,47 @@ async function startWorker(options) {
5885
6085
  if (shutdownAfterClose) {
5886
6086
  process.exit(0);
5887
6087
  }
6088
+ if (!startupRecoveryComplete) {
6089
+ process.stderr.write("[r5d-worker] control connection closed during workspace recovery; finishing recovery before reconnecting\n");
6090
+ void startupRecoveryPromise.then(() => {
6091
+ if (!startupRecoveryComplete) {
6092
+ process.exit(1);
6093
+ return;
6094
+ }
6095
+ const reconnectDelayMs = workerReconnectDelayMs();
6096
+ process.stderr.write(`[r5d-worker] workspace recovery complete; reconnecting in ${reconnectDelayMs}ms
6097
+ `);
6098
+ setTimeout(() => {
6099
+ void startWorker(options).catch((error) => {
6100
+ process.stderr.write(`[r5d-worker] reconnect failed: ${error instanceof Error ? error.message : String(error)}
6101
+ `);
6102
+ process.exit(error instanceof WorkerServerUnavailableError ? WORKER_RECONNECT_EXIT_CODE : 1);
6103
+ });
6104
+ }, reconnectDelayMs);
6105
+ });
6106
+ return;
6107
+ }
5888
6108
  if (activeProcesses.size > 0 || credentialBearingProcessGroups.size > 0 || activePtys.size > 0 || pendingProcessTerminals.size > 0) {
5889
- process.stderr.write(
5890
- `[r5d-worker] ${activeProcesses.size} process(es), ${credentialBearingProcessGroups.size} owned process group(s), and ${activePtys.size} PTY(s) active with ${pendingProcessTerminals.size} terminal report(s) pending; reconnecting in ${WORKER_RECONNECT_DELAY_MS}ms
6109
+ const scheduleReconnect = (reconnect2) => {
6110
+ const reconnectDelayMs = workerReconnectDelayMs();
6111
+ process.stderr.write(
6112
+ `[r5d-worker] ${activeProcesses.size} process(es), ${credentialBearingProcessGroups.size} owned process group(s), and ${activePtys.size} PTY(s) active with ${pendingProcessTerminals.size} terminal report(s) pending; reconnecting in ${reconnectDelayMs}ms
5891
6113
  `
5892
- );
6114
+ );
6115
+ setTimeout(reconnect2, reconnectDelayMs);
6116
+ };
5893
6117
  const reconnect = () => {
5894
6118
  void startWorker(options).catch((error) => {
5895
6119
  process.stderr.write(`[r5d-worker] reconnect failed: ${error instanceof Error ? error.message : String(error)}
5896
6120
  `);
5897
6121
  if (activeProcesses.size > 0 || credentialBearingProcessGroups.size > 0 || activePtys.size > 0 || pendingProcessTerminals.size > 0) {
5898
- setTimeout(reconnect, WORKER_RECONNECT_DELAY_MS);
6122
+ scheduleReconnect(reconnect);
5899
6123
  return;
5900
6124
  }
5901
6125
  process.exit(error instanceof WorkerServerUnavailableError ? WORKER_RECONNECT_EXIT_CODE : 1);
5902
6126
  });
5903
6127
  };
5904
- setTimeout(reconnect, WORKER_RECONNECT_DELAY_MS);
6128
+ scheduleReconnect(reconnect);
5905
6129
  return;
5906
6130
  }
5907
6131
  process.exit(WORKER_RECONNECT_EXIT_CODE);
@@ -5911,10 +6135,28 @@ async function startWorker(options) {
5911
6135
  process.stderr.write(`[r5d-worker] websocket error${message}
5912
6136
  `);
5913
6137
  });
5914
- await new Promise(() => {
6138
+ await new Promise((resolve) => {
6139
+ ws.addEventListener("close", () => resolve(), { once: true });
6140
+ });
6141
+ }
6142
+ function installWorkerRuntimeFailureLogging() {
6143
+ process.on("uncaughtException", (error) => {
6144
+ process.stderr.write(`[r5d-worker] uncaught exception: ${error instanceof Error ? error.stack ?? error.message : String(error)}
6145
+ `);
6146
+ });
6147
+ process.on("unhandledRejection", (reason) => {
6148
+ process.stderr.write(
6149
+ `[r5d-worker] unhandled rejection: ${reason instanceof Error ? reason.stack ?? reason.message : String(reason)}
6150
+ `
6151
+ );
5915
6152
  });
5916
6153
  }
5917
6154
  async function main() {
6155
+ if (process.argv[2] === PROJECT_SNAPSHOT_RECOVERY_HELPER_COMMAND) {
6156
+ const exitCode2 = runProjectSnapshotRecoveryHelper(process.argv.slice(3));
6157
+ if (exitCode2 !== 0) process.exit(exitCode2);
6158
+ return;
6159
+ }
5918
6160
  const parsed = parseArgs(process.argv.slice(2));
5919
6161
  if (parsed.command === "help") {
5920
6162
  printHelp();
@@ -5926,6 +6168,7 @@ async function main() {
5926
6168
  }
5927
6169
  if (process.env[WORKER_RUNTIME_ENV] === "1") {
5928
6170
  delete process.env[WORKER_RUNTIME_ENV];
6171
+ installWorkerRuntimeFailureLogging();
5929
6172
  try {
5930
6173
  await startWorker(parsed.options);
5931
6174
  } catch (error) {
@@ -5978,6 +6221,7 @@ export {
5978
6221
  syncSessionArtifacts,
5979
6222
  workerChildProcessEnvironment,
5980
6223
  workerGitSecurityTestHarness,
6224
+ workerLifecycleTestHarness,
5981
6225
  workerPtyBridgeTestHarness,
5982
6226
  workerPtyTestHarness,
5983
6227
  writeWorkerTextFile
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.90",
3
+ "version": "0.0.92",
4
4
  "type": "module"
5
5
  }