@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 +3 -1
- package/dist/cjs/main.cjs +288 -46
- package/dist/cjs/package.json +1 -1
- package/dist/cjs/project-snapshot-recovery-runner.cjs +171 -0
- package/dist/cjs/project-worktrees.cjs +183 -23
- package/dist/cjs/supervisor.cjs +15 -5
- package/dist/mjs/main.mjs +293 -49
- package/dist/mjs/package.json +1 -1
- package/dist/mjs/project-snapshot-recovery-runner.mjs +135 -0
- package/dist/mjs/project-worktrees.mjs +183 -23
- package/dist/mjs/supervisor.mjs +12 -4
- package/dist/types/main.d.ts +24 -0
- package/dist/types/project-snapshot-recovery-runner.d.ts +10 -0
- package/dist/types/project-worktrees.d.ts +13 -1
- package/dist/types/supervisor.d.ts +4 -1
- package/package.json +1 -1
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
|
|
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
|
|
3305
|
-
for (const ptyProcess of
|
|
3306
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5886
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/dist/cjs/package.json
CHANGED