@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/README.md CHANGED
@@ -41,7 +41,9 @@ Worker labels are mandatory and unique per user. Choose labels that describe hos
41
41
 
42
42
  The supervisor also reconnects automatically after server rollouts and transient network failures. A brief bounded delay prevents a tight retry loop while the server is unavailable. Pressing Ctrl+C still stops the worker, and agent-managed processes retain their existing in-process recovery path while disconnected.
43
43
 
44
- The server registry is the sole heartbeat timeout authority. The worker answers server pings but does not run an independent connection watchdog, because synchronous bounded workspace operations can temporarily occupy its event loop. The server keeps those operations leased for their bounded lifetime and closes genuinely stale connections after the lease ends.
44
+ The runtime opens its control connection before recovering interrupted project snapshots. During recovery and initial project configuration it remains connected but non-dispatchable, reports its current phase plus branch/byte progress to the server, and prints the same progress locally. Recovery runs in a credential-sanitized helper process so the control loop can continue answering heartbeats. A durable `restoring` transaction state permits same-filesystem snapshots to be restored with one atomic directory move; cross-filesystem layouts retain the copy-and-fsync fallback.
45
+
46
+ Both sides use monotonic, two-stage heartbeat recovery. The server sends pings, grants bounded leases to synchronous workspace and command-preparation work, and requires a fresh missed probe window before force-evicting a stale socket. The worker answers pings and uses a deliberately slower backup watchdog only for a half-open connection; reconnect delays are jittered across workers. A delayed watchdog sweep renews the probe instead of disconnecting, so synchronous local work or a stalled server event loop cannot turn one overdue timer callback into a mass disconnect.
45
47
 
46
48
  Credential-generation restart fencing protects against stale or accidentally detached descendants. It is not a hostile same-UID sandbox: code running as the worker's OS user can read or tamper with that user's config and auth files, install a persistent user service, or return after reboot. Run untrusted agents in a disposable VM that is reset at the boundary, or use a separately privileged credential broker/service account. When rotating the worker's own local `r5dctl` credential, stop the worker and reboot/reset the host before writing the replacement credential so an old same-UID descendant cannot read the new config.
47
49
 
package/dist/cjs/main.cjs CHANGED
@@ -50,6 +50,7 @@ __export(main_exports, {
50
50
  syncSessionArtifacts: () => syncSessionArtifacts,
51
51
  workerChildProcessEnvironment: () => workerChildProcessEnvironment,
52
52
  workerGitSecurityTestHarness: () => workerGitSecurityTestHarness,
53
+ workerLifecycleTestHarness: () => workerLifecycleTestHarness,
53
54
  workerPtyBridgeTestHarness: () => workerPtyBridgeTestHarness,
54
55
  workerPtyTestHarness: () => workerPtyTestHarness,
55
56
  writeWorkerTextFile: () => writeWorkerTextFile
@@ -80,9 +81,25 @@ var import_repository_transition_policy = require("./repository-transition-polic
80
81
  var import_supervisor = require("./supervisor.cjs");
81
82
  var import_managed_paths = require("./managed-paths.cjs");
82
83
  var import_project_worktrees = require("./project-worktrees.cjs");
84
+ var import_project_snapshot_recovery_runner = require("./project-snapshot-recovery-runner.cjs");
83
85
  var import_project_workspace_state = require("./project-workspace-state.cjs");
84
86
  var import_workspace_git_sync = require("./workspace-git-sync.cjs");
85
87
  const import_meta = {};
88
+ const WORKER_LIFECYCLE_DETAIL_MAX_LENGTH = 500;
89
+ const WORKER_SERVER_HEARTBEAT_INTERVAL_MS = 15e3;
90
+ const WORKER_SERVER_HEARTBEAT_TIMEOUT_MS = 2 * 60 * 1e3;
91
+ function boundedWorkerLifecycleProgress(progress) {
92
+ return progress.detail === void 0 ? progress : { ...progress, detail: progress.detail.slice(0, WORKER_LIFECYCLE_DETAIL_MAX_LENGTH) };
93
+ }
94
+ function workerServerHeartbeatAction(input) {
95
+ if (input.elapsedMs < input.timeoutMs) return "wait";
96
+ if (input.watchdogSweepDelayed || input.probeElapsedMs === void 0) return "start_probe";
97
+ return input.probeElapsedMs >= input.timeoutMs ? "terminate" : "wait";
98
+ }
99
+ const workerLifecycleTestHarness = {
100
+ boundedWorkerLifecycleProgress,
101
+ workerServerHeartbeatAction
102
+ };
86
103
  class ProjectWorkspaceConfigurationDeferredError extends Error {
87
104
  }
88
105
  class WorkerServerUnavailableError extends Error {
@@ -2596,6 +2613,13 @@ async function executeCommand(input) {
2596
2613
  const startedAt = Date.now();
2597
2614
  let timeout;
2598
2615
  let spawnedProcess;
2616
+ let preparationSettled = false;
2617
+ const settlePreparation = () => {
2618
+ if (preparationSettled) return;
2619
+ preparationSettled = true;
2620
+ input.setPreparationHeartbeatLease(false);
2621
+ };
2622
+ input.setPreparationHeartbeatLease(true);
2599
2623
  try {
2600
2624
  const targetProcessEnv = await prepareShellEnvForTarget({
2601
2625
  target: input.resolvedTarget.target,
@@ -2633,6 +2657,7 @@ async function executeCommand(input) {
2633
2657
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
2634
2658
  ...input.message.workspaceEffect === "none" ? { workspaceEffect: "none" } : {}
2635
2659
  });
2660
+ settlePreparation();
2636
2661
  if (input.message.timeoutMs) {
2637
2662
  timeout = setTimeout(() => {
2638
2663
  void (0, import_process_tree.terminateProcessTree)(subprocess).catch((error) => {
@@ -2668,6 +2693,7 @@ async function executeCommand(input) {
2668
2693
  error: error instanceof Error ? error.message : String(error)
2669
2694
  };
2670
2695
  } finally {
2696
+ settlePreparation();
2671
2697
  if (timeout) {
2672
2698
  clearTimeout(timeout);
2673
2699
  }
@@ -2683,6 +2709,13 @@ async function executeStreamingCommand(input) {
2683
2709
  let timedOut = false;
2684
2710
  let timeout;
2685
2711
  let spawnedProcess;
2712
+ let preparationSettled = false;
2713
+ const settlePreparation = () => {
2714
+ if (preparationSettled) return;
2715
+ preparationSettled = true;
2716
+ input.setPreparationHeartbeatLease(false);
2717
+ };
2718
+ input.setPreparationHeartbeatLease(true);
2686
2719
  try {
2687
2720
  if (cancelledProcessRuns.delete(input.message.runId)) {
2688
2721
  sendWorkerMessage(input.ws, {
@@ -2735,6 +2768,7 @@ async function executeStreamingCommand(input) {
2735
2768
  ...interactive ? { interactive: true, stdin: subprocess.stdin } : {},
2736
2769
  ...input.message.workspaceEffect === "none" ? { workspaceEffect: "none" } : {}
2737
2770
  });
2771
+ settlePreparation();
2738
2772
  started = true;
2739
2773
  sendWorkerMessage(input.ws, {
2740
2774
  type: "exec_started",
@@ -2811,6 +2845,7 @@ async function executeStreamingCommand(input) {
2811
2845
  });
2812
2846
  }
2813
2847
  } finally {
2848
+ settlePreparation();
2814
2849
  if (timeout) {
2815
2850
  clearTimeout(timeout);
2816
2851
  }
@@ -3152,7 +3187,8 @@ function createNodePtyBridge(options) {
3152
3187
  };
3153
3188
  }
3154
3189
  const workerPtyBridgeTestHarness = {
3155
- create: createNodePtyBridge
3190
+ create: createNodePtyBridge,
3191
+ closeAllBestEffort: closePtysBestEffort
3156
3192
  };
3157
3193
  function resolveHostShell(command, platform = process.platform) {
3158
3194
  if (platform === "win32") {
@@ -3301,11 +3337,23 @@ function closePty(message) {
3301
3337
  }
3302
3338
  ptyProcess.kill();
3303
3339
  }
3304
- function closeAllPtys() {
3305
- for (const ptyProcess of activePtys.values()) {
3306
- ptyProcess.kill();
3340
+ function closePtysBestEffort(ptys, reportFailure) {
3341
+ for (const [ptyId, ptyProcess] of ptys) {
3342
+ try {
3343
+ ptyProcess.kill();
3344
+ } catch (error) {
3345
+ reportFailure(ptyId, error);
3346
+ }
3307
3347
  }
3308
3348
  }
3349
+ function closeAllPtys() {
3350
+ closePtysBestEffort(activePtys, (ptyId, error) => {
3351
+ process.stderr.write(
3352
+ `[r5d-worker] failed to close PTY ${ptyId} after control disconnect: ${error instanceof Error ? error.message : String(error)}
3353
+ `
3354
+ );
3355
+ });
3356
+ }
3309
3357
  async function terminateCredentialBearingChildren(children) {
3310
3358
  const results = await Promise.allSettled(children.map(async ({ terminate }) => await terminate()));
3311
3359
  const failures = results.flatMap(
@@ -3365,16 +3413,6 @@ function websocketUrl(baseUrl, label) {
3365
3413
  }
3366
3414
  async function startWorker(options) {
3367
3415
  advanceWorkerAdmissionGeneration();
3368
- process.on("uncaughtException", (error) => {
3369
- process.stderr.write(`[r5d-worker] uncaught exception: ${error instanceof Error ? error.stack ?? error.message : String(error)}
3370
- `);
3371
- });
3372
- process.on("unhandledRejection", (reason) => {
3373
- process.stderr.write(
3374
- `[r5d-worker] unhandled rejection: ${reason instanceof Error ? reason.stack ?? reason.message : String(reason)}
3375
- `
3376
- );
3377
- });
3378
3416
  const config = readConfig(options.configPath);
3379
3417
  const { baseUrl, token } = resolveCredentials(options, config);
3380
3418
  initializeCredentialStoreDirectoryOnce();
@@ -3407,28 +3445,6 @@ async function startWorker(options) {
3407
3445
  runGit(["--version"]);
3408
3446
  await verifyR5dctlAuth(baseUrl, token, label);
3409
3447
  const initializedWorkspaceState = await workspaceSyncSingleFlight.runExclusive(() => {
3410
- if (!startupProjectSnapshotRecoveryCompleted) {
3411
- const snapshotRecovery = (0, import_project_worktrees.recoverStaleProjectWorktreeSnapshots)({
3412
- projectsRoot,
3413
- temporaryRoot: projectWorktreeSnapshotsRoot
3414
- });
3415
- if (snapshotRecovery.restored.length > 0) {
3416
- process.stdout.write(`[r5d-worker] restored ${snapshotRecovery.restored.length} interrupted project snapshot(s)
3417
- `);
3418
- }
3419
- if (snapshotRecovery.removed.length > 0) {
3420
- process.stdout.write(`[r5d-worker] removed ${snapshotRecovery.removed.length} completed/incomplete project snapshot(s)
3421
- `);
3422
- }
3423
- for (const failure of snapshotRecovery.failed) {
3424
- process.stderr.write(`[r5d-worker] failed to recover stale project snapshot ${failure.path}: ${failure.error}
3425
- `);
3426
- }
3427
- if (snapshotRecovery.failed.length > 0) {
3428
- throw new Error("Project snapshot recovery is incomplete; refusing to mutate project checkouts");
3429
- }
3430
- startupProjectSnapshotRecoveryCompleted = true;
3431
- }
3432
3448
  import_node_fs.default.mkdirSync(projectsRoot, { recursive: true });
3433
3449
  import_node_fs.default.mkdirSync(syncRoot, { recursive: true });
3434
3450
  import_node_fs.default.mkdirSync(artifactRoot, { recursive: true });
@@ -3467,6 +3483,8 @@ async function startWorker(options) {
3467
3483
  let cliUpdateInProgress = false;
3468
3484
  let reloadAfterClose = false;
3469
3485
  let shutdownAfterClose = false;
3486
+ let startupRecoveryComplete = startupProjectSnapshotRecoveryCompleted;
3487
+ let startupRecoveryPromise = Promise.resolve();
3470
3488
  const automaticWorkspaceSyncIsCurrentlyAdmitted = () => workspaceAutomaticSyncIsAdmitted({
3471
3489
  workspaceConfigured,
3472
3490
  activeIncidentId: activeWorkspaceIncidentId,
@@ -3883,8 +3901,14 @@ async function startWorker(options) {
3883
3901
  })
3884
3902
  )
3885
3903
  ].sort();
3886
- const ensureConfiguredProjects = async () => {
3887
- for (const project of projectConfigById.values()) {
3904
+ const ensureConfiguredProjects = async (onProgress) => {
3905
+ const projects = [...projectConfigById.values()];
3906
+ for (const [projectIndex, project] of projects.entries()) {
3907
+ onProgress?.({
3908
+ detail: `Configuring ${project.projectPath}`,
3909
+ completedItems: projectIndex,
3910
+ totalItems: projects.length
3911
+ });
3888
3912
  validateProjectId(project.projectId);
3889
3913
  (0, import_repository_transition_policy.assertRepositoryTransitionState)(project);
3890
3914
  for (const branch of project.branches) validateBranchName(branch.branchName);
@@ -3932,7 +3956,14 @@ async function startWorker(options) {
3932
3956
  mirrorUrl: connection.mirrorUrl,
3933
3957
  mirrorCredentialUsername: connection.mirrorCredentialUsername,
3934
3958
  credentialHelper: connection.credentialHelper,
3935
- gitIdentity: workspaceGitIdentity
3959
+ gitIdentity: workspaceGitIdentity,
3960
+ onSnapshotProgress: (progress) => onProgress?.({
3961
+ 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}` : ""}`,
3962
+ completedBytes: progress.completedBytes,
3963
+ completedItems: progress.completedBranches,
3964
+ totalItems: progress.totalBranches,
3965
+ ...progress.totalBytes === void 0 ? {} : { totalBytes: progress.totalBytes }
3966
+ })
3936
3967
  });
3937
3968
  readyProjectIds.add(project.projectId);
3938
3969
  reconciledProjectConfigFingerprints.set(project.projectId, configFingerprint);
@@ -3967,6 +3998,11 @@ async function startWorker(options) {
3967
3998
  `
3968
3999
  );
3969
4000
  }
4001
+ onProgress?.({
4002
+ detail: `Configured ${project.projectPath}`,
4003
+ completedItems: projectIndex + 1,
4004
+ totalItems: projects.length
4005
+ });
3970
4006
  }
3971
4007
  };
3972
4008
  const collectAheadOfOriginBranches = async () => {
@@ -4359,6 +4395,11 @@ async function startWorker(options) {
4359
4395
  if (input.sendResult !== false) sendWorkspaceSyncResult(input.requestId, result2);
4360
4396
  return result2;
4361
4397
  }
4398
+ sendLifecycleProgress({
4399
+ phase: "syncing",
4400
+ operationId: attemptId,
4401
+ detail: input.trigger.detail ?? `Synchronizing workspace (${input.trigger.type})`
4402
+ });
4362
4403
  if (!input.requestId && input.sendResult !== false) {
4363
4404
  if (currentWorkerSocket !== ws || ws.readyState !== WebSocket.OPEN) {
4364
4405
  throw new Error("Cannot start autonomous workspace synchronization without an open worker control socket");
@@ -4461,6 +4502,9 @@ async function startWorker(options) {
4461
4502
  }
4462
4503
  };
4463
4504
  if (input.sendResult !== false) sendWorkspaceSyncResult(input.requestId, result);
4505
+ sendLifecycleProgress(
4506
+ result.outcome === "failed" ? { phase: "failed", operationId: attemptId, detail: result.error ?? "Workspace synchronization failed" } : { phase: "ready", operationId: attemptId, detail: "Workspace synchronized" }
4507
+ );
4464
4508
  return result;
4465
4509
  };
4466
4510
  const scheduleAutomaticWorkspaceSync = (trigger, delayMs = WORKSPACE_GIT_QUIET_MS) => {
@@ -4535,6 +4579,14 @@ async function startWorker(options) {
4535
4579
  });
4536
4580
  };
4537
4581
  const configureWorkerWorkspace = async (message, receiptGeneration) => {
4582
+ const configurationOperationId = crypto.randomUUID();
4583
+ sendLifecycleProgress({
4584
+ phase: "configuring",
4585
+ operationId: configurationOperationId,
4586
+ detail: `Preparing ${message.projects.length} project(s)`,
4587
+ completedItems: 0,
4588
+ totalItems: message.projects.length
4589
+ });
4538
4590
  const incidentDeferral = workspaceConfigurationIncidentDeferral({
4539
4591
  requestedIncidentId: message.deferWorkspaceSyncForIncidentId,
4540
4592
  activeIncidentId: activeWorkspaceIncidentId
@@ -4899,7 +4951,13 @@ async function startWorker(options) {
4899
4951
  // checkout initialization from erasing synchronized dirt.
4900
4952
  { ignoreBusy: true }
4901
4953
  );
4902
- await ensureConfiguredProjects();
4954
+ await ensureConfiguredProjects((progress) => {
4955
+ sendLifecycleProgress({ phase: "configuring", operationId: configurationOperationId, ...progress });
4956
+ process.stdout.write(
4957
+ `[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)`}
4958
+ `
4959
+ );
4960
+ });
4903
4961
  const configurationSyncAdmissionGeneration = workerAdmissionGeneration;
4904
4962
  const assertConfigurationSyncStillAdmitted = () => {
4905
4963
  if (receiptGeneration !== workspaceConfigurationReceiptGeneration || configurationSyncAdmissionGeneration !== workerAdmissionGeneration || !workspaceConfigurationIncidentSnapshotIsCurrent(incidentDeferral.incidentId, activeWorkspaceIncidentId) || currentWorkerSocket !== ws || ws.readyState !== WebSocket.OPEN) {
@@ -4998,6 +5056,73 @@ async function startWorker(options) {
4998
5056
  Authorization: `Bearer ${token}`
4999
5057
  }
5000
5058
  });
5059
+ let lastServerControlMessageAtMs = performance.now();
5060
+ let serverHeartbeatProbeStartedAtMs;
5061
+ let lastServerHeartbeatSweepAtMs = performance.now();
5062
+ let serverHeartbeatWatchdog;
5063
+ const noteServerControlActivity = () => {
5064
+ lastServerControlMessageAtMs = performance.now();
5065
+ serverHeartbeatProbeStartedAtMs = void 0;
5066
+ };
5067
+ const stopServerHeartbeatWatchdog = () => {
5068
+ if (!serverHeartbeatWatchdog) return;
5069
+ clearInterval(serverHeartbeatWatchdog);
5070
+ serverHeartbeatWatchdog = void 0;
5071
+ };
5072
+ const sendLifecycleProgress = (progress) => {
5073
+ if (currentWorkerSocket !== ws || ws.readyState !== WebSocket.OPEN) return;
5074
+ sendWorkerMessage(ws, { type: "worker_progress", progress: boundedWorkerLifecycleProgress(progress) });
5075
+ };
5076
+ const describeSnapshotProgress = (progress) => {
5077
+ const project = import_node_path.default.relative(projectsRoot, progress.projectRoot) || import_node_path.default.basename(progress.projectRoot);
5078
+ const branch = progress.branchName ? `/${progress.branchName}` : "";
5079
+ if (progress.phase === "scanning") return `Scanning ${project} recovery snapshot`;
5080
+ if (progress.phase === "syncing") return `Making restored ${project} files durable`;
5081
+ if (progress.phase === "cleaning") return `Cleaning recovered ${project} snapshot`;
5082
+ return `Restoring ${project}${branch}`;
5083
+ };
5084
+ const performStartupRecovery = async (operationId) => {
5085
+ if (startupProjectSnapshotRecoveryCompleted) return;
5086
+ sendLifecycleProgress({ phase: "recovering", operationId, detail: "Checking interrupted workspace transactions" });
5087
+ const snapshotRecovery = await workspaceSyncSingleFlight.runExclusive(
5088
+ async () => await (0, import_project_snapshot_recovery_runner.recoverProjectSnapshotsInChild)({
5089
+ projectsRoot,
5090
+ snapshotRoot: projectWorktreeSnapshotsRoot,
5091
+ onProgress: (progress) => {
5092
+ const lifecycleProgress = {
5093
+ phase: "recovering",
5094
+ operationId,
5095
+ detail: describeSnapshotProgress(progress),
5096
+ completedBytes: progress.completedBytes,
5097
+ completedItems: progress.completedBranches,
5098
+ totalItems: progress.totalBranches,
5099
+ ...progress.totalBytes === void 0 ? {} : { totalBytes: progress.totalBytes }
5100
+ };
5101
+ sendLifecycleProgress(lifecycleProgress);
5102
+ process.stdout.write(
5103
+ `[r5d-worker] ${lifecycleProgress.detail}: ${progress.completedBranches}/${progress.totalBranches} branch(es), ${progress.completedBytes}/${progress.totalBytes ?? "?"} bytes
5104
+ `
5105
+ );
5106
+ }
5107
+ })
5108
+ );
5109
+ if (snapshotRecovery.restored.length > 0) {
5110
+ process.stdout.write(`[r5d-worker] restored ${snapshotRecovery.restored.length} interrupted project snapshot(s)
5111
+ `);
5112
+ }
5113
+ if (snapshotRecovery.removed.length > 0) {
5114
+ process.stdout.write(`[r5d-worker] removed ${snapshotRecovery.removed.length} completed/incomplete project snapshot(s)
5115
+ `);
5116
+ }
5117
+ for (const failure of snapshotRecovery.failed) {
5118
+ process.stderr.write(`[r5d-worker] failed to recover stale project snapshot ${failure.path}: ${failure.error}
5119
+ `);
5120
+ }
5121
+ if (snapshotRecovery.failed.length > 0) {
5122
+ throw new Error("Project snapshot recovery is incomplete; refusing to mutate project checkouts");
5123
+ }
5124
+ startupProjectSnapshotRecoveryCompleted = true;
5125
+ };
5001
5126
  const handleGracefulShutdown = () => {
5002
5127
  if (shutdownAfterClose) return;
5003
5128
  shutdownAfterClose = true;
@@ -5027,8 +5152,50 @@ async function startWorker(options) {
5027
5152
  process.on("SIGINT", handleGracefulShutdown);
5028
5153
  ws.addEventListener("open", () => {
5029
5154
  currentWorkerSocket = ws;
5155
+ noteServerControlActivity();
5156
+ lastServerHeartbeatSweepAtMs = performance.now();
5157
+ serverHeartbeatWatchdog = setInterval(() => {
5158
+ if (currentWorkerSocket !== ws || ws.readyState !== WebSocket.OPEN) return;
5159
+ const nowMs = performance.now();
5160
+ const heartbeatAction = workerServerHeartbeatAction({
5161
+ elapsedMs: Math.max(0, nowMs - lastServerControlMessageAtMs),
5162
+ ...serverHeartbeatProbeStartedAtMs === void 0 ? {} : { probeElapsedMs: Math.max(0, nowMs - serverHeartbeatProbeStartedAtMs) },
5163
+ timeoutMs: WORKER_SERVER_HEARTBEAT_TIMEOUT_MS,
5164
+ watchdogSweepDelayed: nowMs - lastServerHeartbeatSweepAtMs >= WORKER_SERVER_HEARTBEAT_INTERVAL_MS * 2
5165
+ });
5166
+ lastServerHeartbeatSweepAtMs = nowMs;
5167
+ if (heartbeatAction === "start_probe") {
5168
+ serverHeartbeatProbeStartedAtMs = nowMs;
5169
+ return;
5170
+ }
5171
+ if (heartbeatAction === "terminate") {
5172
+ stopServerHeartbeatWatchdog();
5173
+ process.stderr.write("[r5d-worker] server control heartbeat timed out; reconnecting\n");
5174
+ ws.terminate();
5175
+ }
5176
+ }, WORKER_SERVER_HEARTBEAT_INTERVAL_MS);
5177
+ serverHeartbeatWatchdog.unref();
5178
+ const startupOperationId = crypto.randomUUID();
5179
+ startupRecoveryPromise = Promise.resolve().then(async () => await performStartupRecovery(startupOperationId)).then(() => {
5180
+ startupRecoveryComplete = true;
5181
+ if (currentWorkerSocket === ws && ws.readyState === WebSocket.OPEN) {
5182
+ sendWorkerMessage(ws, { type: "startup_ready" });
5183
+ sendLifecycleProgress({
5184
+ phase: "configuring",
5185
+ operationId: crypto.randomUUID(),
5186
+ detail: "Waiting for workspace configuration"
5187
+ });
5188
+ }
5189
+ }).catch((error) => {
5190
+ const message = error instanceof Error ? error.message : String(error);
5191
+ sendLifecycleProgress({ phase: "failed", operationId: startupOperationId, detail: message });
5192
+ process.stderr.write(`[r5d-worker] startup recovery failed: ${message}
5193
+ `);
5194
+ ws.close(1011, "Project snapshot recovery failed");
5195
+ });
5030
5196
  const hello = {
5031
5197
  type: "hello",
5198
+ startupReady: startupRecoveryComplete,
5032
5199
  hostInfo: {
5033
5200
  hostname: (0, import_node_os.hostname)(),
5034
5201
  platform: process.platform,
@@ -5064,8 +5231,11 @@ async function startWorker(options) {
5064
5231
  process.stdout.write("[r5d-worker] connected\n");
5065
5232
  });
5066
5233
  ws.addEventListener("message", (event) => {
5234
+ noteServerControlActivity();
5067
5235
  void (async () => {
5068
5236
  const message = JSON.parse(String(event.data));
5237
+ if (!startupRecoveryComplete && message.type !== "ping") await startupRecoveryPromise;
5238
+ if (!startupRecoveryComplete && message.type !== "ping") return;
5069
5239
  const messageAdmissionGeneration = workerAdmissionGeneration;
5070
5240
  const assertMessageAdmission = () => assertWorkerChildAdmission({
5071
5241
  capturedGeneration: messageAdmissionGeneration,
@@ -5093,6 +5263,19 @@ async function startWorker(options) {
5093
5263
  aheadOfOriginBranches: configured.aheadOfOriginBranches,
5094
5264
  ...configured.deferredWorkspaceSyncForIncidentId ? { deferredWorkspaceSyncForIncidentId: configured.deferredWorkspaceSyncForIncidentId } : {}
5095
5265
  });
5266
+ sendLifecycleProgress(
5267
+ configured.result.outcome === "failed" ? {
5268
+ phase: "failed",
5269
+ operationId: configured.result.attemptId,
5270
+ detail: configured.result.error ?? "Workspace configuration failed"
5271
+ } : {
5272
+ phase: "ready",
5273
+ operationId: configured.result.attemptId,
5274
+ detail: `${message.projects.length} project(s) configured`,
5275
+ completedItems: message.projects.length,
5276
+ totalItems: message.projects.length
5277
+ }
5278
+ );
5096
5279
  process.stdout.write(
5097
5280
  `[r5d-worker] workspace configured: ${message.projects.length} project(s), ${configured.pending.length} pending checkout(s)
5098
5281
  `
@@ -5656,6 +5839,13 @@ async function startWorker(options) {
5656
5839
  token,
5657
5840
  artifactRoot,
5658
5841
  planRoot,
5842
+ setPreparationHeartbeatLease: (active) => {
5843
+ sendWorkerMessageFromCurrentSource(ws, {
5844
+ type: "heartbeat_lease",
5845
+ operationId: `exec:${message.runId}`,
5846
+ active
5847
+ });
5848
+ },
5659
5849
  assertAdmission: () => {
5660
5850
  if (!workspaceCommandTransportIsAllowed(deferredWorkspaceConfiguration, activeWorkspaceIncidentId, "exec")) {
5661
5851
  throw new Error(
@@ -5719,6 +5909,13 @@ async function startWorker(options) {
5719
5909
  token,
5720
5910
  artifactRoot,
5721
5911
  planRoot,
5912
+ setPreparationHeartbeatLease: (active) => {
5913
+ sendWorkerMessageFromCurrentSource(ws, {
5914
+ type: "heartbeat_lease",
5915
+ operationId: `exec:${message.runId}`,
5916
+ active
5917
+ });
5918
+ },
5722
5919
  assertAdmission: assertMessageAdmission
5723
5920
  });
5724
5921
  } finally {
@@ -5851,6 +6048,7 @@ async function startWorker(options) {
5851
6048
  const processEvents = process;
5852
6049
  processEvents.off("SIGTERM", handleGracefulShutdown);
5853
6050
  processEvents.off("SIGINT", handleGracefulShutdown);
6051
+ stopServerHeartbeatWatchdog();
5854
6052
  if (workspaceAutomaticTimer) {
5855
6053
  clearTimeout(workspaceAutomaticTimer);
5856
6054
  workspaceAutomaticTimer = void 0;
@@ -5881,23 +6079,47 @@ async function startWorker(options) {
5881
6079
  if (shutdownAfterClose) {
5882
6080
  process.exit(0);
5883
6081
  }
6082
+ if (!startupRecoveryComplete) {
6083
+ process.stderr.write("[r5d-worker] control connection closed during workspace recovery; finishing recovery before reconnecting\n");
6084
+ void startupRecoveryPromise.then(() => {
6085
+ if (!startupRecoveryComplete) {
6086
+ process.exit(1);
6087
+ return;
6088
+ }
6089
+ const reconnectDelayMs = (0, import_supervisor.workerReconnectDelayMs)();
6090
+ process.stderr.write(`[r5d-worker] workspace recovery complete; reconnecting in ${reconnectDelayMs}ms
6091
+ `);
6092
+ setTimeout(() => {
6093
+ void startWorker(options).catch((error) => {
6094
+ process.stderr.write(`[r5d-worker] reconnect failed: ${error instanceof Error ? error.message : String(error)}
6095
+ `);
6096
+ process.exit(error instanceof WorkerServerUnavailableError ? import_supervisor.WORKER_RECONNECT_EXIT_CODE : 1);
6097
+ });
6098
+ }, reconnectDelayMs);
6099
+ });
6100
+ return;
6101
+ }
5884
6102
  if (activeProcesses.size > 0 || credentialBearingProcessGroups.size > 0 || activePtys.size > 0 || pendingProcessTerminals.size > 0) {
5885
- process.stderr.write(
5886
- `[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 ${import_supervisor.WORKER_RECONNECT_DELAY_MS}ms
6103
+ const scheduleReconnect = (reconnect2) => {
6104
+ const reconnectDelayMs = (0, import_supervisor.workerReconnectDelayMs)();
6105
+ process.stderr.write(
6106
+ `[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
5887
6107
  `
5888
- );
6108
+ );
6109
+ setTimeout(reconnect2, reconnectDelayMs);
6110
+ };
5889
6111
  const reconnect = () => {
5890
6112
  void startWorker(options).catch((error) => {
5891
6113
  process.stderr.write(`[r5d-worker] reconnect failed: ${error instanceof Error ? error.message : String(error)}
5892
6114
  `);
5893
6115
  if (activeProcesses.size > 0 || credentialBearingProcessGroups.size > 0 || activePtys.size > 0 || pendingProcessTerminals.size > 0) {
5894
- setTimeout(reconnect, import_supervisor.WORKER_RECONNECT_DELAY_MS);
6116
+ scheduleReconnect(reconnect);
5895
6117
  return;
5896
6118
  }
5897
6119
  process.exit(error instanceof WorkerServerUnavailableError ? import_supervisor.WORKER_RECONNECT_EXIT_CODE : 1);
5898
6120
  });
5899
6121
  };
5900
- setTimeout(reconnect, import_supervisor.WORKER_RECONNECT_DELAY_MS);
6122
+ scheduleReconnect(reconnect);
5901
6123
  return;
5902
6124
  }
5903
6125
  process.exit(import_supervisor.WORKER_RECONNECT_EXIT_CODE);
@@ -5907,10 +6129,28 @@ async function startWorker(options) {
5907
6129
  process.stderr.write(`[r5d-worker] websocket error${message}
5908
6130
  `);
5909
6131
  });
5910
- await new Promise(() => {
6132
+ await new Promise((resolve) => {
6133
+ ws.addEventListener("close", () => resolve(), { once: true });
6134
+ });
6135
+ }
6136
+ function installWorkerRuntimeFailureLogging() {
6137
+ process.on("uncaughtException", (error) => {
6138
+ process.stderr.write(`[r5d-worker] uncaught exception: ${error instanceof Error ? error.stack ?? error.message : String(error)}
6139
+ `);
6140
+ });
6141
+ process.on("unhandledRejection", (reason) => {
6142
+ process.stderr.write(
6143
+ `[r5d-worker] unhandled rejection: ${reason instanceof Error ? reason.stack ?? reason.message : String(reason)}
6144
+ `
6145
+ );
5911
6146
  });
5912
6147
  }
5913
6148
  async function main() {
6149
+ if (process.argv[2] === import_project_snapshot_recovery_runner.PROJECT_SNAPSHOT_RECOVERY_HELPER_COMMAND) {
6150
+ const exitCode2 = (0, import_project_snapshot_recovery_runner.runProjectSnapshotRecoveryHelper)(process.argv.slice(3));
6151
+ if (exitCode2 !== 0) process.exit(exitCode2);
6152
+ return;
6153
+ }
5914
6154
  const parsed = parseArgs(process.argv.slice(2));
5915
6155
  if (parsed.command === "help") {
5916
6156
  printHelp();
@@ -5922,6 +6162,7 @@ async function main() {
5922
6162
  }
5923
6163
  if (process.env[import_supervisor.WORKER_RUNTIME_ENV] === "1") {
5924
6164
  delete process.env[import_supervisor.WORKER_RUNTIME_ENV];
6165
+ installWorkerRuntimeFailureLogging();
5925
6166
  try {
5926
6167
  await startWorker(parsed.options);
5927
6168
  } catch (error) {
@@ -5975,6 +6216,7 @@ if (isCliEntrypoint()) {
5975
6216
  syncSessionArtifacts,
5976
6217
  workerChildProcessEnvironment,
5977
6218
  workerGitSecurityTestHarness,
6219
+ workerLifecycleTestHarness,
5978
6220
  workerPtyBridgeTestHarness,
5979
6221
  workerPtyTestHarness,
5980
6222
  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": "commonjs"
5
5
  }