@ricsam/r5d-worker 0.0.88 → 0.0.90
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 +2 -0
- package/dist/cjs/main.cjs +100 -42
- package/dist/cjs/package.json +1 -1
- package/dist/cjs/workspace-git-sync.cjs +11 -2
- package/dist/mjs/main.mjs +101 -47
- package/dist/mjs/package.json +1 -1
- package/dist/mjs/workspace-git-sync.mjs +10 -2
- package/dist/types/main.d.ts +30 -3
- package/dist/types/workspace-git-sync.d.ts +10 -0
- package/package.json +1 -1
- package/dist/cjs/heartbeat.cjs +0 -49
- package/dist/mjs/heartbeat.mjs +0 -21
- package/dist/types/heartbeat.d.ts +0 -10
package/README.md
CHANGED
|
@@ -41,6 +41,8 @@ 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.
|
|
45
|
+
|
|
44
46
|
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.
|
|
45
47
|
|
|
46
48
|
One-click updates use the current user's global npm prefix and never invoke `sudo`. If that prefix is not writable, User Settings provides the exact command to run manually.
|
package/dist/cjs/main.cjs
CHANGED
|
@@ -63,7 +63,6 @@ var import_node_child_process = require("node:child_process");
|
|
|
63
63
|
var import_bun_sqlite = require("bun:sqlite");
|
|
64
64
|
var import_cli_update = require("./cli-update.cjs");
|
|
65
65
|
var import_git_process_environment = require("./git-process-environment.cjs");
|
|
66
|
-
var import_heartbeat = require("./heartbeat.cjs");
|
|
67
66
|
var import_process_tree = require("./process-tree.cjs");
|
|
68
67
|
var import_pty_output_coalescer = require("./pty-output-coalescer.cjs");
|
|
69
68
|
var import_port_forward_client = require("./port-forward-client.cjs");
|
|
@@ -150,6 +149,11 @@ class StaleWorkerAdmissionError extends Error {
|
|
|
150
149
|
this.name = "StaleWorkerAdmissionError";
|
|
151
150
|
}
|
|
152
151
|
}
|
|
152
|
+
class WorkspaceSyncAdmissionChangedError extends Error {
|
|
153
|
+
name = "WorkspaceSyncAdmissionChangedError";
|
|
154
|
+
}
|
|
155
|
+
let unsafeWorkspaceSyncRecoveryAttempts = 0;
|
|
156
|
+
let unsafeWorkspaceSyncBouncePending = false;
|
|
153
157
|
function advanceWorkerAdmissionGeneration() {
|
|
154
158
|
workerAdmissionGeneration += 1;
|
|
155
159
|
return workerAdmissionGeneration;
|
|
@@ -1633,16 +1637,33 @@ function workspaceSyncFailureHydrationIsSafe(input) {
|
|
|
1633
1637
|
if (input.error instanceof import_workspace_git_sync.WorkspaceRemediationAncestryError) return true;
|
|
1634
1638
|
if (input.resetToCanonical) return false;
|
|
1635
1639
|
try {
|
|
1636
|
-
return input.
|
|
1640
|
+
return input.inspectRecoverableHydration();
|
|
1637
1641
|
} catch {
|
|
1638
1642
|
return false;
|
|
1639
1643
|
}
|
|
1640
1644
|
}
|
|
1645
|
+
const WORKSPACE_UNSAFE_SYNC_MAX_RECOVERY_ATTEMPTS = 3;
|
|
1646
|
+
const WORKSPACE_UNSAFE_SYNC_RECOVERY_CLOSE_CODE = 4008;
|
|
1647
|
+
function unsafeWorkspaceSyncRecoveryBounceDelayMs(attempt) {
|
|
1648
|
+
return Math.min(1e3 * 2 ** Math.max(0, attempt - 1), 3e4);
|
|
1649
|
+
}
|
|
1650
|
+
function unsafeWorkspaceSyncFailureResponse(input) {
|
|
1651
|
+
if (input.resetToCanonical) return "fatal_exit";
|
|
1652
|
+
if (!input.hasRetainableWork) return "fatal_exit";
|
|
1653
|
+
if (input.recoveryAttempts >= input.maxRecoveryAttempts) return "fatal_exit";
|
|
1654
|
+
if (input.bouncePending) return "recovery_pending";
|
|
1655
|
+
return "recovery_bounce";
|
|
1656
|
+
}
|
|
1641
1657
|
function fenceUnsafeWorkspaceSyncFailure(input) {
|
|
1642
|
-
if (input.
|
|
1658
|
+
if (input.hydrationRecoverable) return "none";
|
|
1643
1659
|
input.invalidateExecution();
|
|
1644
|
-
|
|
1645
|
-
|
|
1660
|
+
const response = unsafeWorkspaceSyncFailureResponse(input);
|
|
1661
|
+
if (response === "fatal_exit") {
|
|
1662
|
+
requireCredentialGenerationRestart(true, input.fatalExit, input.exitCode);
|
|
1663
|
+
throw new Error("Unsafe workspace synchronization fence continued past its fatal exit");
|
|
1664
|
+
}
|
|
1665
|
+
if (response === "recovery_bounce") input.scheduleRecoveryBounce();
|
|
1666
|
+
return response;
|
|
1646
1667
|
}
|
|
1647
1668
|
const workerGitSecurityTestHarness = {
|
|
1648
1669
|
commandArgs: workerGitCommand.commandArgs,
|
|
@@ -1670,6 +1691,11 @@ const workerGitSecurityTestHarness = {
|
|
|
1670
1691
|
credentialReapContractStatus,
|
|
1671
1692
|
verifiedCredentialReapContract,
|
|
1672
1693
|
fenceUnsafeWorkspaceSyncFailure,
|
|
1694
|
+
unsafeWorkspaceSyncFailureResponse,
|
|
1695
|
+
unsafeWorkspaceSyncRecoveryBounceDelayMs,
|
|
1696
|
+
WORKSPACE_UNSAFE_SYNC_MAX_RECOVERY_ATTEMPTS,
|
|
1697
|
+
WORKSPACE_UNSAFE_SYNC_RECOVERY_CLOSE_CODE,
|
|
1698
|
+
WorkspaceSyncAdmissionChangedError,
|
|
1673
1699
|
workspaceConfigurationIncidentDeferral,
|
|
1674
1700
|
workspaceIncidentTerminalClearDisposition,
|
|
1675
1701
|
workspaceIncidentTerminalRefreshFence,
|
|
@@ -4286,6 +4312,28 @@ async function startWorker(options) {
|
|
|
4286
4312
|
);
|
|
4287
4313
|
}
|
|
4288
4314
|
};
|
|
4315
|
+
const scheduleUnsafeWorkspaceSyncRecoveryBounce = (cause) => {
|
|
4316
|
+
unsafeWorkspaceSyncBouncePending = true;
|
|
4317
|
+
const attempt = unsafeWorkspaceSyncRecoveryAttempts;
|
|
4318
|
+
if (currentWorkerSocket !== ws || ws.readyState !== WebSocket.OPEN) {
|
|
4319
|
+
process.stderr.write(
|
|
4320
|
+
`[r5d-worker] workspace synchronization failed without a recoverable hydration basis; awaiting reconnect recovery (attempt ${attempt} of ${WORKSPACE_UNSAFE_SYNC_MAX_RECOVERY_ATTEMPTS}): ${cause}
|
|
4321
|
+
`
|
|
4322
|
+
);
|
|
4323
|
+
return;
|
|
4324
|
+
}
|
|
4325
|
+
process.stderr.write(
|
|
4326
|
+
`[r5d-worker] workspace synchronization failed without a recoverable hydration basis; bouncing the worker socket for in-process recovery (attempt ${attempt} of ${WORKSPACE_UNSAFE_SYNC_MAX_RECOVERY_ATTEMPTS}): ${cause}
|
|
4327
|
+
`
|
|
4328
|
+
);
|
|
4329
|
+
const timer = setTimeout(() => {
|
|
4330
|
+
if (!unsafeWorkspaceSyncBouncePending) return;
|
|
4331
|
+
if (currentWorkerSocket === ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
|
|
4332
|
+
ws.close(WORKSPACE_UNSAFE_SYNC_RECOVERY_CLOSE_CODE, "Workspace synchronization failed without a recoverable hydration basis");
|
|
4333
|
+
}
|
|
4334
|
+
}, unsafeWorkspaceSyncRecoveryBounceDelayMs(attempt));
|
|
4335
|
+
timer.unref();
|
|
4336
|
+
};
|
|
4289
4337
|
const runWorkspaceSync = async (input) => {
|
|
4290
4338
|
const attemptId = input.attemptId ?? crypto.randomUUID();
|
|
4291
4339
|
const requestedAt = Date.now();
|
|
@@ -4294,7 +4342,7 @@ async function startWorker(options) {
|
|
|
4294
4342
|
const admittedDeferredIncidentId = deferredWorkspaceConfiguration?.incidentId ?? null;
|
|
4295
4343
|
const assertStillAdmitted = () => {
|
|
4296
4344
|
if (admittedGeneration !== workerAdmissionGeneration || admittedActiveIncidentId !== activeWorkspaceIncidentId || admittedDeferredIncidentId !== (deferredWorkspaceConfiguration?.incidentId ?? null) || currentWorkerSocket !== ws || ws.readyState !== WebSocket.OPEN || !deferredWorkspaceSyncTriggerIsAllowed(deferredWorkspaceConfiguration, activeWorkspaceIncidentId, input.trigger)) {
|
|
4297
|
-
throw new
|
|
4345
|
+
throw new WorkspaceSyncAdmissionChangedError("Workspace synchronization admission changed while asynchronous work was in flight");
|
|
4298
4346
|
}
|
|
4299
4347
|
};
|
|
4300
4348
|
if (!deferredWorkspaceSyncTriggerIsAllowed(deferredWorkspaceConfiguration, activeWorkspaceIncidentId, input.trigger)) {
|
|
@@ -4346,31 +4394,62 @@ async function startWorker(options) {
|
|
|
4346
4394
|
assertStillAdmitted
|
|
4347
4395
|
});
|
|
4348
4396
|
} catch (error) {
|
|
4349
|
-
const
|
|
4397
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
4398
|
+
const hydrationRecoverable = workspaceSyncFailureHydrationIsSafe({
|
|
4350
4399
|
error,
|
|
4351
4400
|
resetToCanonical: input.resetToCanonical === true,
|
|
4352
|
-
|
|
4401
|
+
inspectRecoverableHydration: () => (0, import_workspace_git_sync.workspaceGitHydrationIsRecoverable)(workspaceShadowRoot, buildWorkspaceMounts())
|
|
4353
4402
|
});
|
|
4354
|
-
if (
|
|
4355
|
-
|
|
4356
|
-
|
|
4403
|
+
if (hydrationRecoverable) {
|
|
4404
|
+
if (error instanceof WorkspaceSyncAdmissionChangedError) {
|
|
4405
|
+
process.stderr.write(
|
|
4406
|
+
`[r5d-worker] workspace synchronization was cancelled with every mount at a recorded basis; the next cycle converges it: ${errorMessage}
|
|
4357
4407
|
`
|
|
4358
|
-
|
|
4408
|
+
);
|
|
4409
|
+
}
|
|
4410
|
+
return await failedWorkspaceSyncResult(attemptId, input.trigger, error);
|
|
4359
4411
|
}
|
|
4360
|
-
fenceUnsafeWorkspaceSyncFailure({
|
|
4361
|
-
|
|
4412
|
+
const fenced = fenceUnsafeWorkspaceSyncFailure({
|
|
4413
|
+
hydrationRecoverable,
|
|
4414
|
+
resetToCanonical: input.resetToCanonical === true,
|
|
4415
|
+
recoveryAttempts: unsafeWorkspaceSyncRecoveryAttempts,
|
|
4416
|
+
maxRecoveryAttempts: WORKSPACE_UNSAFE_SYNC_MAX_RECOVERY_ATTEMPTS,
|
|
4417
|
+
hasRetainableWork: activeProcesses.size > 0 || credentialBearingProcessGroups.size > 0 || activePtys.size > 0 || pendingProcessTerminals.size > 0,
|
|
4418
|
+
bouncePending: unsafeWorkspaceSyncBouncePending,
|
|
4362
4419
|
invalidateExecution: () => {
|
|
4363
4420
|
workspaceConfigured = false;
|
|
4364
4421
|
advanceWorkerAdmissionGeneration();
|
|
4365
4422
|
},
|
|
4423
|
+
scheduleRecoveryBounce: () => {
|
|
4424
|
+
unsafeWorkspaceSyncRecoveryAttempts += 1;
|
|
4425
|
+
scheduleUnsafeWorkspaceSyncRecoveryBounce(errorMessage);
|
|
4426
|
+
},
|
|
4427
|
+
fatalExit: (exitCode) => {
|
|
4428
|
+
process.stderr.write(
|
|
4429
|
+
`[r5d-worker] workspace synchronization failed without a recoverable hydration basis; exiting for exact recovery after ${unsafeWorkspaceSyncRecoveryAttempts} in-process recovery attempt(s): ${errorMessage}
|
|
4430
|
+
`
|
|
4431
|
+
);
|
|
4432
|
+
process.exit(exitCode);
|
|
4433
|
+
},
|
|
4366
4434
|
exitCode: 1
|
|
4367
4435
|
});
|
|
4368
|
-
|
|
4436
|
+
if (fenced === "recovery_pending") {
|
|
4437
|
+
process.stderr.write(
|
|
4438
|
+
`[r5d-worker] workspace synchronization failed without a recoverable hydration basis while in-process recovery is already pending: ${errorMessage}
|
|
4439
|
+
`
|
|
4440
|
+
);
|
|
4441
|
+
}
|
|
4442
|
+
return {
|
|
4443
|
+
...await failedWorkspaceSyncResult(attemptId, input.trigger, error),
|
|
4444
|
+
failureReason: "unsafe_hydration",
|
|
4445
|
+
recoveryAttempt: unsafeWorkspaceSyncRecoveryAttempts
|
|
4446
|
+
};
|
|
4369
4447
|
}
|
|
4370
4448
|
});
|
|
4371
4449
|
} finally {
|
|
4372
4450
|
workspaceSyncRequestsInFlight -= 1;
|
|
4373
4451
|
}
|
|
4452
|
+
if (result.outcome !== "failed") unsafeWorkspaceSyncRecoveryAttempts = 0;
|
|
4374
4453
|
const finishedAt = Date.now();
|
|
4375
4454
|
result = {
|
|
4376
4455
|
...result,
|
|
@@ -4421,7 +4500,6 @@ async function startWorker(options) {
|
|
|
4421
4500
|
);
|
|
4422
4501
|
}).finally(() => {
|
|
4423
4502
|
automaticSyncInFlight = false;
|
|
4424
|
-
heartbeatBusyGrace = (0, import_heartbeat.grantWorkerHeartbeatBusyGrace)(lastServerHeartbeatAt, heartbeatBusyGrace);
|
|
4425
4503
|
if (pendingAutomaticTrigger && automaticWorkspaceSyncIsCurrentlyAdmitted()) {
|
|
4426
4504
|
scheduleAutomaticWorkspaceSync(pendingAutomaticTrigger, WORKSPACE_GIT_QUIET_MS);
|
|
4427
4505
|
}
|
|
@@ -4825,7 +4903,9 @@ async function startWorker(options) {
|
|
|
4825
4903
|
const configurationSyncAdmissionGeneration = workerAdmissionGeneration;
|
|
4826
4904
|
const assertConfigurationSyncStillAdmitted = () => {
|
|
4827
4905
|
if (receiptGeneration !== workspaceConfigurationReceiptGeneration || configurationSyncAdmissionGeneration !== workerAdmissionGeneration || !workspaceConfigurationIncidentSnapshotIsCurrent(incidentDeferral.incidentId, activeWorkspaceIncidentId) || currentWorkerSocket !== ws || ws.readyState !== WebSocket.OPEN) {
|
|
4828
|
-
throw new
|
|
4906
|
+
throw new WorkspaceSyncAdmissionChangedError(
|
|
4907
|
+
"Workspace configuration synchronization admission changed while asynchronous work was in flight"
|
|
4908
|
+
);
|
|
4829
4909
|
}
|
|
4830
4910
|
};
|
|
4831
4911
|
const result = await performWorkspaceSync({
|
|
@@ -4918,10 +4998,6 @@ async function startWorker(options) {
|
|
|
4918
4998
|
Authorization: `Bearer ${token}`
|
|
4919
4999
|
}
|
|
4920
5000
|
});
|
|
4921
|
-
let lastServerHeartbeatAt = null;
|
|
4922
|
-
let heartbeatBusyGrace = null;
|
|
4923
|
-
let heartbeatTimedOut = false;
|
|
4924
|
-
let heartbeatWatchdog;
|
|
4925
5001
|
const handleGracefulShutdown = () => {
|
|
4926
5002
|
if (shutdownAfterClose) return;
|
|
4927
5003
|
shutdownAfterClose = true;
|
|
@@ -4949,23 +5025,8 @@ async function startWorker(options) {
|
|
|
4949
5025
|
};
|
|
4950
5026
|
process.on("SIGTERM", handleGracefulShutdown);
|
|
4951
5027
|
process.on("SIGINT", handleGracefulShutdown);
|
|
4952
|
-
const stopHeartbeatWatchdog = () => {
|
|
4953
|
-
if (heartbeatWatchdog) {
|
|
4954
|
-
clearInterval(heartbeatWatchdog);
|
|
4955
|
-
heartbeatWatchdog = void 0;
|
|
4956
|
-
}
|
|
4957
|
-
};
|
|
4958
5028
|
ws.addEventListener("open", () => {
|
|
4959
5029
|
currentWorkerSocket = ws;
|
|
4960
|
-
heartbeatWatchdog = setInterval(() => {
|
|
4961
|
-
if (heartbeatTimedOut || !(0, import_heartbeat.hasWorkerHeartbeatTimedOutWithBusyGrace)(lastServerHeartbeatAt, heartbeatBusyGrace)) {
|
|
4962
|
-
return;
|
|
4963
|
-
}
|
|
4964
|
-
heartbeatTimedOut = true;
|
|
4965
|
-
stopHeartbeatWatchdog();
|
|
4966
|
-
process.stderr.write("[r5d-worker] server heartbeat timed out; reconnecting\n");
|
|
4967
|
-
ws.terminate();
|
|
4968
|
-
}, import_heartbeat.WORKER_HEARTBEAT_INTERVAL_MS);
|
|
4969
5030
|
const hello = {
|
|
4970
5031
|
type: "hello",
|
|
4971
5032
|
hostInfo: {
|
|
@@ -5012,7 +5073,7 @@ async function startWorker(options) {
|
|
|
5012
5073
|
sourceSocketIsCurrent: currentWorkerSocket === ws,
|
|
5013
5074
|
sourceSocketOpen: ws.readyState === WebSocket.OPEN,
|
|
5014
5075
|
workspaceConfigured,
|
|
5015
|
-
runtimeHealthy: !shutdownAfterClose
|
|
5076
|
+
runtimeHealthy: !shutdownAfterClose
|
|
5016
5077
|
});
|
|
5017
5078
|
if (message.type === "connected") {
|
|
5018
5079
|
return;
|
|
@@ -5022,6 +5083,8 @@ async function startWorker(options) {
|
|
|
5022
5083
|
const refreshesDeferredConfiguration = deferredWorkspaceConfiguration !== null && activeWorkspaceIncidentId === null && message.deferWorkspaceSyncForIncidentId === void 0;
|
|
5023
5084
|
advanceWorkerAdmissionGeneration();
|
|
5024
5085
|
const configured = await configureWorkerWorkspace(message, receiptGeneration);
|
|
5086
|
+
unsafeWorkspaceSyncBouncePending = false;
|
|
5087
|
+
if (configured.result.outcome !== "failed") unsafeWorkspaceSyncRecoveryAttempts = 0;
|
|
5025
5088
|
sendWorkerMessageFromCurrentSource(ws, {
|
|
5026
5089
|
type: "workspace_configured",
|
|
5027
5090
|
requestId: message.requestId,
|
|
@@ -5369,8 +5432,6 @@ async function startWorker(options) {
|
|
|
5369
5432
|
return;
|
|
5370
5433
|
}
|
|
5371
5434
|
if (message.type === "ping") {
|
|
5372
|
-
lastServerHeartbeatAt = Date.now();
|
|
5373
|
-
heartbeatBusyGrace = null;
|
|
5374
5435
|
ws.send(JSON.stringify({ type: "pong" }));
|
|
5375
5436
|
return;
|
|
5376
5437
|
}
|
|
@@ -5784,15 +5845,12 @@ async function startWorker(options) {
|
|
|
5784
5845
|
})().catch((error) => {
|
|
5785
5846
|
process.stderr.write(`[r5d-worker] message handling failed: ${error instanceof Error ? error.message : String(error)}
|
|
5786
5847
|
`);
|
|
5787
|
-
}).finally(() => {
|
|
5788
|
-
heartbeatBusyGrace = (0, import_heartbeat.grantWorkerHeartbeatBusyGrace)(lastServerHeartbeatAt, heartbeatBusyGrace);
|
|
5789
5848
|
});
|
|
5790
5849
|
});
|
|
5791
5850
|
ws.addEventListener("close", (event) => {
|
|
5792
5851
|
const processEvents = process;
|
|
5793
5852
|
processEvents.off("SIGTERM", handleGracefulShutdown);
|
|
5794
5853
|
processEvents.off("SIGINT", handleGracefulShutdown);
|
|
5795
|
-
stopHeartbeatWatchdog();
|
|
5796
5854
|
if (workspaceAutomaticTimer) {
|
|
5797
5855
|
clearTimeout(workspaceAutomaticTimer);
|
|
5798
5856
|
workspaceAutomaticTimer = void 0;
|
package/dist/cjs/package.json
CHANGED
|
@@ -42,6 +42,7 @@ __export(workspace_git_sync_exports, {
|
|
|
42
42
|
resetWorkspaceGit: () => resetWorkspaceGit,
|
|
43
43
|
synchronizeWorkspaceGit: () => synchronizeWorkspaceGit,
|
|
44
44
|
workspaceGitHydrationIsCurrent: () => workspaceGitHydrationIsCurrent,
|
|
45
|
+
workspaceGitHydrationIsRecoverable: () => workspaceGitHydrationIsRecoverable,
|
|
45
46
|
workspaceGitMountsForRemediation: () => workspaceGitMountsForRemediation,
|
|
46
47
|
workspaceGitSyncTestHarness: () => workspaceGitSyncTestHarness
|
|
47
48
|
});
|
|
@@ -420,18 +421,25 @@ function hydratedWorkspaceReceiptFileIsV3(workspacePath) {
|
|
|
420
421
|
}
|
|
421
422
|
}
|
|
422
423
|
function workspaceGitHydrationIsCurrent(workspacePath, mounts) {
|
|
424
|
+
return workspaceGitHydrationHolds(workspacePath, mounts, false);
|
|
425
|
+
}
|
|
426
|
+
function workspaceGitHydrationIsRecoverable(workspacePath, mounts) {
|
|
427
|
+
return workspaceGitHydrationHolds(workspacePath, mounts, true);
|
|
428
|
+
}
|
|
429
|
+
function workspaceGitHydrationHolds(workspacePath, mounts, tolerateIdleRecordedBasis) {
|
|
423
430
|
const resolvedWorkspacePath = import_node_path.default.resolve(workspacePath);
|
|
424
431
|
if (!import_node_fs.default.existsSync(import_node_path.default.join(resolvedWorkspacePath, ".git"))) return true;
|
|
425
432
|
if (mounts) validateMounts(resolvedWorkspacePath, mounts);
|
|
426
433
|
const head = revParse(resolvedWorkspacePath, "HEAD");
|
|
427
434
|
if (!workspaceCheckoutDurabilityIsCurrent(resolvedWorkspacePath)) return false;
|
|
435
|
+
if (tolerateIdleRecordedBasis && lstatIfExists(hydrationTransactionPath(resolvedWorkspacePath)) !== null) return false;
|
|
428
436
|
const receipt = readHydratedWorkspaceReceipt(resolvedWorkspacePath);
|
|
429
437
|
if (!workspaceBasisRefsAreCurrent(resolvedWorkspacePath, receipt ?? { version: 3, mounts: [] })) return false;
|
|
430
438
|
if (head === null) return true;
|
|
431
439
|
if (!receipt) return false;
|
|
432
440
|
return mounts ? configuredWorkspaceHydrationBasisMounts(resolvedWorkspacePath, mounts).every(
|
|
433
|
-
(mount) => workspaceHydrationReceiptCoversMount(receipt, head, mount) || Boolean(mount.busy?.() && workspaceHydrationReceiptMountBasisHead(receipt, mount))
|
|
434
|
-
) : Boolean(receipt && receipt.mounts.every((mount) => mount.head === head));
|
|
441
|
+
(mount) => workspaceHydrationReceiptCoversMount(receipt, head, mount) || Boolean((tolerateIdleRecordedBasis || mount.busy?.()) && workspaceHydrationReceiptMountBasisHead(receipt, mount))
|
|
442
|
+
) : Boolean(receipt && receipt.mounts.every((mount) => tolerateIdleRecordedBasis || mount.head === head));
|
|
435
443
|
}
|
|
436
444
|
const WORKSPACE_GIT_BASIS_REF_PREFIX = "refs/r5d/workspace-basis/";
|
|
437
445
|
const WORKSPACE_GIT_BASIS_STAGING_REF_PREFIX = "refs/r5d/workspace-basis-staging/";
|
|
@@ -2190,6 +2198,7 @@ const workspaceGitSyncTestHarness = {
|
|
|
2190
2198
|
resetWorkspaceGit,
|
|
2191
2199
|
synchronizeWorkspaceGit,
|
|
2192
2200
|
workspaceGitHydrationIsCurrent,
|
|
2201
|
+
workspaceGitHydrationIsRecoverable,
|
|
2193
2202
|
workspaceGitMountsForRemediation,
|
|
2194
2203
|
workspaceGitSyncTestHarness
|
|
2195
2204
|
});
|
package/dist/mjs/main.mjs
CHANGED
|
@@ -7,11 +7,6 @@ import { spawn as spawnChildProcess } from "node:child_process";
|
|
|
7
7
|
import { Database } from "bun:sqlite";
|
|
8
8
|
import { installCliUpdate, readInstalledCliVersion } from "./cli-update.mjs";
|
|
9
9
|
import { gitTransportSecurityArgs, workerGitProcessEnvironment } from "./git-process-environment.mjs";
|
|
10
|
-
import {
|
|
11
|
-
grantWorkerHeartbeatBusyGrace,
|
|
12
|
-
hasWorkerHeartbeatTimedOutWithBusyGrace,
|
|
13
|
-
WORKER_HEARTBEAT_INTERVAL_MS
|
|
14
|
-
} from "./heartbeat.mjs";
|
|
15
10
|
import { terminateProcessTree } from "./process-tree.mjs";
|
|
16
11
|
import { createPtyOutputCoalescer } from "./pty-output-coalescer.mjs";
|
|
17
12
|
import { openWorkerPortForwardRelay } from "./port-forward-client.mjs";
|
|
@@ -88,7 +83,7 @@ import {
|
|
|
88
83
|
recoverWorkspaceGitHydration,
|
|
89
84
|
resetWorkspaceGit,
|
|
90
85
|
synchronizeWorkspaceGit,
|
|
91
|
-
|
|
86
|
+
workspaceGitHydrationIsRecoverable,
|
|
92
87
|
workspaceGitMountsForRemediation,
|
|
93
88
|
WorkspaceRemediationAncestryError
|
|
94
89
|
} from "./workspace-git-sync.mjs";
|
|
@@ -158,6 +153,11 @@ class StaleWorkerAdmissionError extends Error {
|
|
|
158
153
|
this.name = "StaleWorkerAdmissionError";
|
|
159
154
|
}
|
|
160
155
|
}
|
|
156
|
+
class WorkspaceSyncAdmissionChangedError extends Error {
|
|
157
|
+
name = "WorkspaceSyncAdmissionChangedError";
|
|
158
|
+
}
|
|
159
|
+
let unsafeWorkspaceSyncRecoveryAttempts = 0;
|
|
160
|
+
let unsafeWorkspaceSyncBouncePending = false;
|
|
161
161
|
function advanceWorkerAdmissionGeneration() {
|
|
162
162
|
workerAdmissionGeneration += 1;
|
|
163
163
|
return workerAdmissionGeneration;
|
|
@@ -1641,16 +1641,33 @@ function workspaceSyncFailureHydrationIsSafe(input) {
|
|
|
1641
1641
|
if (input.error instanceof WorkspaceRemediationAncestryError) return true;
|
|
1642
1642
|
if (input.resetToCanonical) return false;
|
|
1643
1643
|
try {
|
|
1644
|
-
return input.
|
|
1644
|
+
return input.inspectRecoverableHydration();
|
|
1645
1645
|
} catch {
|
|
1646
1646
|
return false;
|
|
1647
1647
|
}
|
|
1648
1648
|
}
|
|
1649
|
+
const WORKSPACE_UNSAFE_SYNC_MAX_RECOVERY_ATTEMPTS = 3;
|
|
1650
|
+
const WORKSPACE_UNSAFE_SYNC_RECOVERY_CLOSE_CODE = 4008;
|
|
1651
|
+
function unsafeWorkspaceSyncRecoveryBounceDelayMs(attempt) {
|
|
1652
|
+
return Math.min(1e3 * 2 ** Math.max(0, attempt - 1), 3e4);
|
|
1653
|
+
}
|
|
1654
|
+
function unsafeWorkspaceSyncFailureResponse(input) {
|
|
1655
|
+
if (input.resetToCanonical) return "fatal_exit";
|
|
1656
|
+
if (!input.hasRetainableWork) return "fatal_exit";
|
|
1657
|
+
if (input.recoveryAttempts >= input.maxRecoveryAttempts) return "fatal_exit";
|
|
1658
|
+
if (input.bouncePending) return "recovery_pending";
|
|
1659
|
+
return "recovery_bounce";
|
|
1660
|
+
}
|
|
1649
1661
|
function fenceUnsafeWorkspaceSyncFailure(input) {
|
|
1650
|
-
if (input.
|
|
1662
|
+
if (input.hydrationRecoverable) return "none";
|
|
1651
1663
|
input.invalidateExecution();
|
|
1652
|
-
|
|
1653
|
-
|
|
1664
|
+
const response = unsafeWorkspaceSyncFailureResponse(input);
|
|
1665
|
+
if (response === "fatal_exit") {
|
|
1666
|
+
requireCredentialGenerationRestart(true, input.fatalExit, input.exitCode);
|
|
1667
|
+
throw new Error("Unsafe workspace synchronization fence continued past its fatal exit");
|
|
1668
|
+
}
|
|
1669
|
+
if (response === "recovery_bounce") input.scheduleRecoveryBounce();
|
|
1670
|
+
return response;
|
|
1654
1671
|
}
|
|
1655
1672
|
const workerGitSecurityTestHarness = {
|
|
1656
1673
|
commandArgs: workerGitCommand.commandArgs,
|
|
@@ -1678,6 +1695,11 @@ const workerGitSecurityTestHarness = {
|
|
|
1678
1695
|
credentialReapContractStatus,
|
|
1679
1696
|
verifiedCredentialReapContract,
|
|
1680
1697
|
fenceUnsafeWorkspaceSyncFailure,
|
|
1698
|
+
unsafeWorkspaceSyncFailureResponse,
|
|
1699
|
+
unsafeWorkspaceSyncRecoveryBounceDelayMs,
|
|
1700
|
+
WORKSPACE_UNSAFE_SYNC_MAX_RECOVERY_ATTEMPTS,
|
|
1701
|
+
WORKSPACE_UNSAFE_SYNC_RECOVERY_CLOSE_CODE,
|
|
1702
|
+
WorkspaceSyncAdmissionChangedError,
|
|
1681
1703
|
workspaceConfigurationIncidentDeferral,
|
|
1682
1704
|
workspaceIncidentTerminalClearDisposition,
|
|
1683
1705
|
workspaceIncidentTerminalRefreshFence,
|
|
@@ -4294,6 +4316,28 @@ async function startWorker(options) {
|
|
|
4294
4316
|
);
|
|
4295
4317
|
}
|
|
4296
4318
|
};
|
|
4319
|
+
const scheduleUnsafeWorkspaceSyncRecoveryBounce = (cause) => {
|
|
4320
|
+
unsafeWorkspaceSyncBouncePending = true;
|
|
4321
|
+
const attempt = unsafeWorkspaceSyncRecoveryAttempts;
|
|
4322
|
+
if (currentWorkerSocket !== ws || ws.readyState !== WebSocket.OPEN) {
|
|
4323
|
+
process.stderr.write(
|
|
4324
|
+
`[r5d-worker] workspace synchronization failed without a recoverable hydration basis; awaiting reconnect recovery (attempt ${attempt} of ${WORKSPACE_UNSAFE_SYNC_MAX_RECOVERY_ATTEMPTS}): ${cause}
|
|
4325
|
+
`
|
|
4326
|
+
);
|
|
4327
|
+
return;
|
|
4328
|
+
}
|
|
4329
|
+
process.stderr.write(
|
|
4330
|
+
`[r5d-worker] workspace synchronization failed without a recoverable hydration basis; bouncing the worker socket for in-process recovery (attempt ${attempt} of ${WORKSPACE_UNSAFE_SYNC_MAX_RECOVERY_ATTEMPTS}): ${cause}
|
|
4331
|
+
`
|
|
4332
|
+
);
|
|
4333
|
+
const timer = setTimeout(() => {
|
|
4334
|
+
if (!unsafeWorkspaceSyncBouncePending) return;
|
|
4335
|
+
if (currentWorkerSocket === ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
|
|
4336
|
+
ws.close(WORKSPACE_UNSAFE_SYNC_RECOVERY_CLOSE_CODE, "Workspace synchronization failed without a recoverable hydration basis");
|
|
4337
|
+
}
|
|
4338
|
+
}, unsafeWorkspaceSyncRecoveryBounceDelayMs(attempt));
|
|
4339
|
+
timer.unref();
|
|
4340
|
+
};
|
|
4297
4341
|
const runWorkspaceSync = async (input) => {
|
|
4298
4342
|
const attemptId = input.attemptId ?? crypto.randomUUID();
|
|
4299
4343
|
const requestedAt = Date.now();
|
|
@@ -4302,7 +4346,7 @@ async function startWorker(options) {
|
|
|
4302
4346
|
const admittedDeferredIncidentId = deferredWorkspaceConfiguration?.incidentId ?? null;
|
|
4303
4347
|
const assertStillAdmitted = () => {
|
|
4304
4348
|
if (admittedGeneration !== workerAdmissionGeneration || admittedActiveIncidentId !== activeWorkspaceIncidentId || admittedDeferredIncidentId !== (deferredWorkspaceConfiguration?.incidentId ?? null) || currentWorkerSocket !== ws || ws.readyState !== WebSocket.OPEN || !deferredWorkspaceSyncTriggerIsAllowed(deferredWorkspaceConfiguration, activeWorkspaceIncidentId, input.trigger)) {
|
|
4305
|
-
throw new
|
|
4349
|
+
throw new WorkspaceSyncAdmissionChangedError("Workspace synchronization admission changed while asynchronous work was in flight");
|
|
4306
4350
|
}
|
|
4307
4351
|
};
|
|
4308
4352
|
if (!deferredWorkspaceSyncTriggerIsAllowed(deferredWorkspaceConfiguration, activeWorkspaceIncidentId, input.trigger)) {
|
|
@@ -4354,31 +4398,62 @@ async function startWorker(options) {
|
|
|
4354
4398
|
assertStillAdmitted
|
|
4355
4399
|
});
|
|
4356
4400
|
} catch (error) {
|
|
4357
|
-
const
|
|
4401
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
4402
|
+
const hydrationRecoverable = workspaceSyncFailureHydrationIsSafe({
|
|
4358
4403
|
error,
|
|
4359
4404
|
resetToCanonical: input.resetToCanonical === true,
|
|
4360
|
-
|
|
4405
|
+
inspectRecoverableHydration: () => workspaceGitHydrationIsRecoverable(workspaceShadowRoot, buildWorkspaceMounts())
|
|
4361
4406
|
});
|
|
4362
|
-
if (
|
|
4363
|
-
|
|
4364
|
-
|
|
4407
|
+
if (hydrationRecoverable) {
|
|
4408
|
+
if (error instanceof WorkspaceSyncAdmissionChangedError) {
|
|
4409
|
+
process.stderr.write(
|
|
4410
|
+
`[r5d-worker] workspace synchronization was cancelled with every mount at a recorded basis; the next cycle converges it: ${errorMessage}
|
|
4365
4411
|
`
|
|
4366
|
-
|
|
4412
|
+
);
|
|
4413
|
+
}
|
|
4414
|
+
return await failedWorkspaceSyncResult(attemptId, input.trigger, error);
|
|
4367
4415
|
}
|
|
4368
|
-
fenceUnsafeWorkspaceSyncFailure({
|
|
4369
|
-
|
|
4416
|
+
const fenced = fenceUnsafeWorkspaceSyncFailure({
|
|
4417
|
+
hydrationRecoverable,
|
|
4418
|
+
resetToCanonical: input.resetToCanonical === true,
|
|
4419
|
+
recoveryAttempts: unsafeWorkspaceSyncRecoveryAttempts,
|
|
4420
|
+
maxRecoveryAttempts: WORKSPACE_UNSAFE_SYNC_MAX_RECOVERY_ATTEMPTS,
|
|
4421
|
+
hasRetainableWork: activeProcesses.size > 0 || credentialBearingProcessGroups.size > 0 || activePtys.size > 0 || pendingProcessTerminals.size > 0,
|
|
4422
|
+
bouncePending: unsafeWorkspaceSyncBouncePending,
|
|
4370
4423
|
invalidateExecution: () => {
|
|
4371
4424
|
workspaceConfigured = false;
|
|
4372
4425
|
advanceWorkerAdmissionGeneration();
|
|
4373
4426
|
},
|
|
4427
|
+
scheduleRecoveryBounce: () => {
|
|
4428
|
+
unsafeWorkspaceSyncRecoveryAttempts += 1;
|
|
4429
|
+
scheduleUnsafeWorkspaceSyncRecoveryBounce(errorMessage);
|
|
4430
|
+
},
|
|
4431
|
+
fatalExit: (exitCode) => {
|
|
4432
|
+
process.stderr.write(
|
|
4433
|
+
`[r5d-worker] workspace synchronization failed without a recoverable hydration basis; exiting for exact recovery after ${unsafeWorkspaceSyncRecoveryAttempts} in-process recovery attempt(s): ${errorMessage}
|
|
4434
|
+
`
|
|
4435
|
+
);
|
|
4436
|
+
process.exit(exitCode);
|
|
4437
|
+
},
|
|
4374
4438
|
exitCode: 1
|
|
4375
4439
|
});
|
|
4376
|
-
|
|
4440
|
+
if (fenced === "recovery_pending") {
|
|
4441
|
+
process.stderr.write(
|
|
4442
|
+
`[r5d-worker] workspace synchronization failed without a recoverable hydration basis while in-process recovery is already pending: ${errorMessage}
|
|
4443
|
+
`
|
|
4444
|
+
);
|
|
4445
|
+
}
|
|
4446
|
+
return {
|
|
4447
|
+
...await failedWorkspaceSyncResult(attemptId, input.trigger, error),
|
|
4448
|
+
failureReason: "unsafe_hydration",
|
|
4449
|
+
recoveryAttempt: unsafeWorkspaceSyncRecoveryAttempts
|
|
4450
|
+
};
|
|
4377
4451
|
}
|
|
4378
4452
|
});
|
|
4379
4453
|
} finally {
|
|
4380
4454
|
workspaceSyncRequestsInFlight -= 1;
|
|
4381
4455
|
}
|
|
4456
|
+
if (result.outcome !== "failed") unsafeWorkspaceSyncRecoveryAttempts = 0;
|
|
4382
4457
|
const finishedAt = Date.now();
|
|
4383
4458
|
result = {
|
|
4384
4459
|
...result,
|
|
@@ -4429,7 +4504,6 @@ async function startWorker(options) {
|
|
|
4429
4504
|
);
|
|
4430
4505
|
}).finally(() => {
|
|
4431
4506
|
automaticSyncInFlight = false;
|
|
4432
|
-
heartbeatBusyGrace = grantWorkerHeartbeatBusyGrace(lastServerHeartbeatAt, heartbeatBusyGrace);
|
|
4433
4507
|
if (pendingAutomaticTrigger && automaticWorkspaceSyncIsCurrentlyAdmitted()) {
|
|
4434
4508
|
scheduleAutomaticWorkspaceSync(pendingAutomaticTrigger, WORKSPACE_GIT_QUIET_MS);
|
|
4435
4509
|
}
|
|
@@ -4833,7 +4907,9 @@ async function startWorker(options) {
|
|
|
4833
4907
|
const configurationSyncAdmissionGeneration = workerAdmissionGeneration;
|
|
4834
4908
|
const assertConfigurationSyncStillAdmitted = () => {
|
|
4835
4909
|
if (receiptGeneration !== workspaceConfigurationReceiptGeneration || configurationSyncAdmissionGeneration !== workerAdmissionGeneration || !workspaceConfigurationIncidentSnapshotIsCurrent(incidentDeferral.incidentId, activeWorkspaceIncidentId) || currentWorkerSocket !== ws || ws.readyState !== WebSocket.OPEN) {
|
|
4836
|
-
throw new
|
|
4910
|
+
throw new WorkspaceSyncAdmissionChangedError(
|
|
4911
|
+
"Workspace configuration synchronization admission changed while asynchronous work was in flight"
|
|
4912
|
+
);
|
|
4837
4913
|
}
|
|
4838
4914
|
};
|
|
4839
4915
|
const result = await performWorkspaceSync({
|
|
@@ -4926,10 +5002,6 @@ async function startWorker(options) {
|
|
|
4926
5002
|
Authorization: `Bearer ${token}`
|
|
4927
5003
|
}
|
|
4928
5004
|
});
|
|
4929
|
-
let lastServerHeartbeatAt = null;
|
|
4930
|
-
let heartbeatBusyGrace = null;
|
|
4931
|
-
let heartbeatTimedOut = false;
|
|
4932
|
-
let heartbeatWatchdog;
|
|
4933
5005
|
const handleGracefulShutdown = () => {
|
|
4934
5006
|
if (shutdownAfterClose) return;
|
|
4935
5007
|
shutdownAfterClose = true;
|
|
@@ -4957,23 +5029,8 @@ async function startWorker(options) {
|
|
|
4957
5029
|
};
|
|
4958
5030
|
process.on("SIGTERM", handleGracefulShutdown);
|
|
4959
5031
|
process.on("SIGINT", handleGracefulShutdown);
|
|
4960
|
-
const stopHeartbeatWatchdog = () => {
|
|
4961
|
-
if (heartbeatWatchdog) {
|
|
4962
|
-
clearInterval(heartbeatWatchdog);
|
|
4963
|
-
heartbeatWatchdog = void 0;
|
|
4964
|
-
}
|
|
4965
|
-
};
|
|
4966
5032
|
ws.addEventListener("open", () => {
|
|
4967
5033
|
currentWorkerSocket = ws;
|
|
4968
|
-
heartbeatWatchdog = setInterval(() => {
|
|
4969
|
-
if (heartbeatTimedOut || !hasWorkerHeartbeatTimedOutWithBusyGrace(lastServerHeartbeatAt, heartbeatBusyGrace)) {
|
|
4970
|
-
return;
|
|
4971
|
-
}
|
|
4972
|
-
heartbeatTimedOut = true;
|
|
4973
|
-
stopHeartbeatWatchdog();
|
|
4974
|
-
process.stderr.write("[r5d-worker] server heartbeat timed out; reconnecting\n");
|
|
4975
|
-
ws.terminate();
|
|
4976
|
-
}, WORKER_HEARTBEAT_INTERVAL_MS);
|
|
4977
5034
|
const hello = {
|
|
4978
5035
|
type: "hello",
|
|
4979
5036
|
hostInfo: {
|
|
@@ -5020,7 +5077,7 @@ async function startWorker(options) {
|
|
|
5020
5077
|
sourceSocketIsCurrent: currentWorkerSocket === ws,
|
|
5021
5078
|
sourceSocketOpen: ws.readyState === WebSocket.OPEN,
|
|
5022
5079
|
workspaceConfigured,
|
|
5023
|
-
runtimeHealthy: !shutdownAfterClose
|
|
5080
|
+
runtimeHealthy: !shutdownAfterClose
|
|
5024
5081
|
});
|
|
5025
5082
|
if (message.type === "connected") {
|
|
5026
5083
|
return;
|
|
@@ -5030,6 +5087,8 @@ async function startWorker(options) {
|
|
|
5030
5087
|
const refreshesDeferredConfiguration = deferredWorkspaceConfiguration !== null && activeWorkspaceIncidentId === null && message.deferWorkspaceSyncForIncidentId === void 0;
|
|
5031
5088
|
advanceWorkerAdmissionGeneration();
|
|
5032
5089
|
const configured = await configureWorkerWorkspace(message, receiptGeneration);
|
|
5090
|
+
unsafeWorkspaceSyncBouncePending = false;
|
|
5091
|
+
if (configured.result.outcome !== "failed") unsafeWorkspaceSyncRecoveryAttempts = 0;
|
|
5033
5092
|
sendWorkerMessageFromCurrentSource(ws, {
|
|
5034
5093
|
type: "workspace_configured",
|
|
5035
5094
|
requestId: message.requestId,
|
|
@@ -5377,8 +5436,6 @@ async function startWorker(options) {
|
|
|
5377
5436
|
return;
|
|
5378
5437
|
}
|
|
5379
5438
|
if (message.type === "ping") {
|
|
5380
|
-
lastServerHeartbeatAt = Date.now();
|
|
5381
|
-
heartbeatBusyGrace = null;
|
|
5382
5439
|
ws.send(JSON.stringify({ type: "pong" }));
|
|
5383
5440
|
return;
|
|
5384
5441
|
}
|
|
@@ -5792,15 +5849,12 @@ async function startWorker(options) {
|
|
|
5792
5849
|
})().catch((error) => {
|
|
5793
5850
|
process.stderr.write(`[r5d-worker] message handling failed: ${error instanceof Error ? error.message : String(error)}
|
|
5794
5851
|
`);
|
|
5795
|
-
}).finally(() => {
|
|
5796
|
-
heartbeatBusyGrace = grantWorkerHeartbeatBusyGrace(lastServerHeartbeatAt, heartbeatBusyGrace);
|
|
5797
5852
|
});
|
|
5798
5853
|
});
|
|
5799
5854
|
ws.addEventListener("close", (event) => {
|
|
5800
5855
|
const processEvents = process;
|
|
5801
5856
|
processEvents.off("SIGTERM", handleGracefulShutdown);
|
|
5802
5857
|
processEvents.off("SIGINT", handleGracefulShutdown);
|
|
5803
|
-
stopHeartbeatWatchdog();
|
|
5804
5858
|
if (workspaceAutomaticTimer) {
|
|
5805
5859
|
clearTimeout(workspaceAutomaticTimer);
|
|
5806
5860
|
workspaceAutomaticTimer = void 0;
|
package/dist/mjs/package.json
CHANGED
|
@@ -376,18 +376,25 @@ function hydratedWorkspaceReceiptFileIsV3(workspacePath) {
|
|
|
376
376
|
}
|
|
377
377
|
}
|
|
378
378
|
function workspaceGitHydrationIsCurrent(workspacePath, mounts) {
|
|
379
|
+
return workspaceGitHydrationHolds(workspacePath, mounts, false);
|
|
380
|
+
}
|
|
381
|
+
function workspaceGitHydrationIsRecoverable(workspacePath, mounts) {
|
|
382
|
+
return workspaceGitHydrationHolds(workspacePath, mounts, true);
|
|
383
|
+
}
|
|
384
|
+
function workspaceGitHydrationHolds(workspacePath, mounts, tolerateIdleRecordedBasis) {
|
|
379
385
|
const resolvedWorkspacePath = path.resolve(workspacePath);
|
|
380
386
|
if (!fs.existsSync(path.join(resolvedWorkspacePath, ".git"))) return true;
|
|
381
387
|
if (mounts) validateMounts(resolvedWorkspacePath, mounts);
|
|
382
388
|
const head = revParse(resolvedWorkspacePath, "HEAD");
|
|
383
389
|
if (!workspaceCheckoutDurabilityIsCurrent(resolvedWorkspacePath)) return false;
|
|
390
|
+
if (tolerateIdleRecordedBasis && lstatIfExists(hydrationTransactionPath(resolvedWorkspacePath)) !== null) return false;
|
|
384
391
|
const receipt = readHydratedWorkspaceReceipt(resolvedWorkspacePath);
|
|
385
392
|
if (!workspaceBasisRefsAreCurrent(resolvedWorkspacePath, receipt ?? { version: 3, mounts: [] })) return false;
|
|
386
393
|
if (head === null) return true;
|
|
387
394
|
if (!receipt) return false;
|
|
388
395
|
return mounts ? configuredWorkspaceHydrationBasisMounts(resolvedWorkspacePath, mounts).every(
|
|
389
|
-
(mount) => workspaceHydrationReceiptCoversMount(receipt, head, mount) || Boolean(mount.busy?.() && workspaceHydrationReceiptMountBasisHead(receipt, mount))
|
|
390
|
-
) : Boolean(receipt && receipt.mounts.every((mount) => mount.head === head));
|
|
396
|
+
(mount) => workspaceHydrationReceiptCoversMount(receipt, head, mount) || Boolean((tolerateIdleRecordedBasis || mount.busy?.()) && workspaceHydrationReceiptMountBasisHead(receipt, mount))
|
|
397
|
+
) : Boolean(receipt && receipt.mounts.every((mount) => tolerateIdleRecordedBasis || mount.head === head));
|
|
391
398
|
}
|
|
392
399
|
const WORKSPACE_GIT_BASIS_REF_PREFIX = "refs/r5d/workspace-basis/";
|
|
393
400
|
const WORKSPACE_GIT_BASIS_STAGING_REF_PREFIX = "refs/r5d/workspace-basis-staging/";
|
|
@@ -2145,6 +2152,7 @@ export {
|
|
|
2145
2152
|
resetWorkspaceGit,
|
|
2146
2153
|
synchronizeWorkspaceGit,
|
|
2147
2154
|
workspaceGitHydrationIsCurrent,
|
|
2155
|
+
workspaceGitHydrationIsRecoverable,
|
|
2148
2156
|
workspaceGitMountsForRemediation,
|
|
2149
2157
|
workspaceGitSyncTestHarness
|
|
2150
2158
|
};
|
package/dist/types/main.d.ts
CHANGED
|
@@ -69,6 +69,10 @@ type WorkspaceSyncResult = {
|
|
|
69
69
|
synchronizeMs: number;
|
|
70
70
|
};
|
|
71
71
|
error?: string;
|
|
72
|
+
/** Typed reason for a failed outcome the server should surface loudly. */
|
|
73
|
+
failureReason?: "unsafe_hydration";
|
|
74
|
+
/** Consecutive in-process recovery bounces taken for unsafe hydration failures. */
|
|
75
|
+
recoveryAttempt?: number;
|
|
72
76
|
};
|
|
73
77
|
export type WorkerSessionTarget = {
|
|
74
78
|
type: "project";
|
|
@@ -356,6 +360,9 @@ type WorkerCodeReadResult = {
|
|
|
356
360
|
mtime: string;
|
|
357
361
|
base64: string;
|
|
358
362
|
};
|
|
363
|
+
declare class WorkspaceSyncAdmissionChangedError extends Error {
|
|
364
|
+
readonly name = "WorkspaceSyncAdmissionChangedError";
|
|
365
|
+
}
|
|
359
366
|
declare function assertWorkerChildAdmission(input: {
|
|
360
367
|
capturedGeneration: number;
|
|
361
368
|
currentGeneration: number;
|
|
@@ -609,14 +616,29 @@ declare function deferredIncidentWorkspaceConfigurationResult(input: {
|
|
|
609
616
|
declare function workspaceSyncFailureHydrationIsSafe(input: {
|
|
610
617
|
error: unknown;
|
|
611
618
|
resetToCanonical: boolean;
|
|
612
|
-
|
|
619
|
+
inspectRecoverableHydration: () => boolean;
|
|
613
620
|
}): boolean;
|
|
621
|
+
declare function unsafeWorkspaceSyncRecoveryBounceDelayMs(attempt: number): number;
|
|
622
|
+
type UnsafeWorkspaceSyncFailureResponse = "fatal_exit" | "recovery_bounce" | "recovery_pending";
|
|
623
|
+
declare function unsafeWorkspaceSyncFailureResponse(input: {
|
|
624
|
+
resetToCanonical: boolean;
|
|
625
|
+
recoveryAttempts: number;
|
|
626
|
+
maxRecoveryAttempts: number;
|
|
627
|
+
hasRetainableWork: boolean;
|
|
628
|
+
bouncePending: boolean;
|
|
629
|
+
}): UnsafeWorkspaceSyncFailureResponse;
|
|
614
630
|
declare function fenceUnsafeWorkspaceSyncFailure(input: {
|
|
615
|
-
|
|
631
|
+
hydrationRecoverable: boolean;
|
|
632
|
+
resetToCanonical: boolean;
|
|
633
|
+
recoveryAttempts: number;
|
|
634
|
+
maxRecoveryAttempts: number;
|
|
635
|
+
hasRetainableWork: boolean;
|
|
636
|
+
bouncePending: boolean;
|
|
616
637
|
invalidateExecution: () => void;
|
|
638
|
+
scheduleRecoveryBounce: () => void;
|
|
617
639
|
fatalExit?: (exitCode: number) => void;
|
|
618
640
|
exitCode?: number;
|
|
619
|
-
}):
|
|
641
|
+
}): "none" | "recovery_bounce" | "recovery_pending";
|
|
620
642
|
export declare const workerGitSecurityTestHarness: {
|
|
621
643
|
commandArgs: (args: string[]) => string[];
|
|
622
644
|
credentialHelperForRemotes: typeof credentialHelperForRemotes;
|
|
@@ -641,6 +663,11 @@ export declare const workerGitSecurityTestHarness: {
|
|
|
641
663
|
credentialReapContractStatus: typeof credentialReapContractStatus;
|
|
642
664
|
verifiedCredentialReapContract: typeof verifiedCredentialReapContract;
|
|
643
665
|
fenceUnsafeWorkspaceSyncFailure: typeof fenceUnsafeWorkspaceSyncFailure;
|
|
666
|
+
unsafeWorkspaceSyncFailureResponse: typeof unsafeWorkspaceSyncFailureResponse;
|
|
667
|
+
unsafeWorkspaceSyncRecoveryBounceDelayMs: typeof unsafeWorkspaceSyncRecoveryBounceDelayMs;
|
|
668
|
+
WORKSPACE_UNSAFE_SYNC_MAX_RECOVERY_ATTEMPTS: number;
|
|
669
|
+
WORKSPACE_UNSAFE_SYNC_RECOVERY_CLOSE_CODE: number;
|
|
670
|
+
WorkspaceSyncAdmissionChangedError: typeof WorkspaceSyncAdmissionChangedError;
|
|
644
671
|
workspaceConfigurationIncidentDeferral: typeof workspaceConfigurationIncidentDeferral;
|
|
645
672
|
workspaceIncidentTerminalClearDisposition: typeof workspaceIncidentTerminalClearDisposition;
|
|
646
673
|
workspaceIncidentTerminalRefreshFence: typeof workspaceIncidentTerminalRefreshFence;
|
|
@@ -71,6 +71,16 @@ export declare class WorkspaceRemediationAncestryError extends Error {
|
|
|
71
71
|
declare function gitCommandArgs(args: string[]): string[];
|
|
72
72
|
declare function workspaceCloneCommandArgs(args: string[], remoteUrl: string, credentialHelper?: string | null, credentialUsername?: string | null): string[];
|
|
73
73
|
export declare function workspaceGitHydrationIsCurrent(workspacePath: string, mounts?: readonly WorkspaceGitMount[]): boolean;
|
|
74
|
+
/**
|
|
75
|
+
* Failure-time variant of the currency check. A mount pinned at a valid
|
|
76
|
+
* recorded basis is convergent whether or not it is busy right now: the next
|
|
77
|
+
* cycle merges it forward from that exact basis and a genuine collision
|
|
78
|
+
* surfaces as a projection_merge conflict, never a silent overwrite. Live
|
|
79
|
+
* busy-ness must not decide safety here because the same disconnect that
|
|
80
|
+
* aborts a sync also clears the process/PTY maps busy-ness is computed from.
|
|
81
|
+
* Configuration-time recovery keeps the strict predicate.
|
|
82
|
+
*/
|
|
83
|
+
export declare function workspaceGitHydrationIsRecoverable(workspacePath: string, mounts?: readonly WorkspaceGitMount[]): boolean;
|
|
74
84
|
declare function configureWorkspaceRepository(input: {
|
|
75
85
|
workspacePath: string;
|
|
76
86
|
remoteUrl: string;
|
package/package.json
CHANGED
package/dist/cjs/heartbeat.cjs
DELETED
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
var __defProp = Object.defineProperty;
|
|
3
|
-
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
-
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
-
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
-
var __export = (target, all) => {
|
|
7
|
-
for (var name in all)
|
|
8
|
-
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
-
};
|
|
10
|
-
var __copyProps = (to, from, except, desc) => {
|
|
11
|
-
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
-
for (let key of __getOwnPropNames(from))
|
|
13
|
-
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
-
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
-
}
|
|
16
|
-
return to;
|
|
17
|
-
};
|
|
18
|
-
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
-
var heartbeat_exports = {};
|
|
20
|
-
__export(heartbeat_exports, {
|
|
21
|
-
WORKER_HEARTBEAT_INTERVAL_MS: () => WORKER_HEARTBEAT_INTERVAL_MS,
|
|
22
|
-
WORKER_HEARTBEAT_TIMEOUT_MS: () => WORKER_HEARTBEAT_TIMEOUT_MS,
|
|
23
|
-
grantWorkerHeartbeatBusyGrace: () => grantWorkerHeartbeatBusyGrace,
|
|
24
|
-
hasWorkerHeartbeatTimedOut: () => hasWorkerHeartbeatTimedOut,
|
|
25
|
-
hasWorkerHeartbeatTimedOutWithBusyGrace: () => hasWorkerHeartbeatTimedOutWithBusyGrace
|
|
26
|
-
});
|
|
27
|
-
module.exports = __toCommonJS(heartbeat_exports);
|
|
28
|
-
const WORKER_HEARTBEAT_INTERVAL_MS = 15e3;
|
|
29
|
-
const WORKER_HEARTBEAT_TIMEOUT_MS = 45e3;
|
|
30
|
-
function hasWorkerHeartbeatTimedOut(lastHeartbeatAt, now = Date.now(), timeoutMs = WORKER_HEARTBEAT_TIMEOUT_MS) {
|
|
31
|
-
return lastHeartbeatAt !== null && now - lastHeartbeatAt >= timeoutMs;
|
|
32
|
-
}
|
|
33
|
-
function grantWorkerHeartbeatBusyGrace(lastHeartbeatAt, currentGrace, now = Date.now(), timeoutMs = WORKER_HEARTBEAT_TIMEOUT_MS, graceMs = WORKER_HEARTBEAT_INTERVAL_MS) {
|
|
34
|
-
if (!hasWorkerHeartbeatTimedOut(lastHeartbeatAt, now, timeoutMs)) return currentGrace;
|
|
35
|
-
if (currentGrace?.heartbeatAt === lastHeartbeatAt) return currentGrace;
|
|
36
|
-
return { heartbeatAt: lastHeartbeatAt, expiresAt: now + graceMs };
|
|
37
|
-
}
|
|
38
|
-
function hasWorkerHeartbeatTimedOutWithBusyGrace(lastHeartbeatAt, busyGrace, now = Date.now(), timeoutMs = WORKER_HEARTBEAT_TIMEOUT_MS) {
|
|
39
|
-
if (lastHeartbeatAt !== null && busyGrace?.heartbeatAt === lastHeartbeatAt && now < busyGrace.expiresAt) return false;
|
|
40
|
-
return hasWorkerHeartbeatTimedOut(lastHeartbeatAt, now, timeoutMs);
|
|
41
|
-
}
|
|
42
|
-
// Annotate the CommonJS export names for ESM import in node:
|
|
43
|
-
0 && (module.exports = {
|
|
44
|
-
WORKER_HEARTBEAT_INTERVAL_MS,
|
|
45
|
-
WORKER_HEARTBEAT_TIMEOUT_MS,
|
|
46
|
-
grantWorkerHeartbeatBusyGrace,
|
|
47
|
-
hasWorkerHeartbeatTimedOut,
|
|
48
|
-
hasWorkerHeartbeatTimedOutWithBusyGrace
|
|
49
|
-
});
|
package/dist/mjs/heartbeat.mjs
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
const WORKER_HEARTBEAT_INTERVAL_MS = 15e3;
|
|
2
|
-
const WORKER_HEARTBEAT_TIMEOUT_MS = 45e3;
|
|
3
|
-
function hasWorkerHeartbeatTimedOut(lastHeartbeatAt, now = Date.now(), timeoutMs = WORKER_HEARTBEAT_TIMEOUT_MS) {
|
|
4
|
-
return lastHeartbeatAt !== null && now - lastHeartbeatAt >= timeoutMs;
|
|
5
|
-
}
|
|
6
|
-
function grantWorkerHeartbeatBusyGrace(lastHeartbeatAt, currentGrace, now = Date.now(), timeoutMs = WORKER_HEARTBEAT_TIMEOUT_MS, graceMs = WORKER_HEARTBEAT_INTERVAL_MS) {
|
|
7
|
-
if (!hasWorkerHeartbeatTimedOut(lastHeartbeatAt, now, timeoutMs)) return currentGrace;
|
|
8
|
-
if (currentGrace?.heartbeatAt === lastHeartbeatAt) return currentGrace;
|
|
9
|
-
return { heartbeatAt: lastHeartbeatAt, expiresAt: now + graceMs };
|
|
10
|
-
}
|
|
11
|
-
function hasWorkerHeartbeatTimedOutWithBusyGrace(lastHeartbeatAt, busyGrace, now = Date.now(), timeoutMs = WORKER_HEARTBEAT_TIMEOUT_MS) {
|
|
12
|
-
if (lastHeartbeatAt !== null && busyGrace?.heartbeatAt === lastHeartbeatAt && now < busyGrace.expiresAt) return false;
|
|
13
|
-
return hasWorkerHeartbeatTimedOut(lastHeartbeatAt, now, timeoutMs);
|
|
14
|
-
}
|
|
15
|
-
export {
|
|
16
|
-
WORKER_HEARTBEAT_INTERVAL_MS,
|
|
17
|
-
WORKER_HEARTBEAT_TIMEOUT_MS,
|
|
18
|
-
grantWorkerHeartbeatBusyGrace,
|
|
19
|
-
hasWorkerHeartbeatTimedOut,
|
|
20
|
-
hasWorkerHeartbeatTimedOutWithBusyGrace
|
|
21
|
-
};
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
export declare const WORKER_HEARTBEAT_INTERVAL_MS = 15000;
|
|
2
|
-
export declare const WORKER_HEARTBEAT_TIMEOUT_MS = 45000;
|
|
3
|
-
export declare function hasWorkerHeartbeatTimedOut(lastHeartbeatAt: number | null, now?: number, timeoutMs?: number): boolean;
|
|
4
|
-
export type WorkerHeartbeatBusyGrace = {
|
|
5
|
-
heartbeatAt: number;
|
|
6
|
-
expiresAt: number;
|
|
7
|
-
};
|
|
8
|
-
/** Grant one bounded grace period for one actual server-heartbeat generation. */
|
|
9
|
-
export declare function grantWorkerHeartbeatBusyGrace(lastHeartbeatAt: number | null, currentGrace: WorkerHeartbeatBusyGrace | null, now?: number, timeoutMs?: number, graceMs?: number): WorkerHeartbeatBusyGrace | null;
|
|
10
|
-
export declare function hasWorkerHeartbeatTimedOutWithBusyGrace(lastHeartbeatAt: number | null, busyGrace: WorkerHeartbeatBusyGrace | null, now?: number, timeoutMs?: number): boolean;
|