@intx/hub-sessions 0.2.2 → 0.4.0

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.
Files changed (87) hide show
  1. package/README.md +3 -5
  2. package/dist/agent-repo.d.ts +23 -7
  3. package/dist/agent-repo.js +19 -6
  4. package/dist/agent-state-kind.js +18 -63
  5. package/dist/asset-service.d.ts +1 -20
  6. package/dist/asset-service.js +19 -97
  7. package/dist/committed-source-tree.d.ts +10 -0
  8. package/dist/committed-source-tree.js +35 -0
  9. package/dist/credential-push.d.ts +52 -7
  10. package/dist/credential-push.js +178 -22
  11. package/dist/event-collector-registry.d.ts +3 -2
  12. package/dist/event-collector-registry.js +42 -13
  13. package/dist/event-collector.d.ts +12 -2
  14. package/dist/event-collector.js +45 -4
  15. package/dist/hub-session-lookups.d.ts +125 -7
  16. package/dist/hub-session-lookups.js +541 -86
  17. package/dist/hub-session-orchestrator.d.ts +2 -3
  18. package/dist/hub-session-orchestrator.js +22 -56
  19. package/dist/index.d.ts +19 -9
  20. package/dist/index.js +16 -7
  21. package/dist/reconciliation-scheduler.d.ts +14 -0
  22. package/dist/reconciliation-scheduler.js +55 -0
  23. package/dist/repo-store/index.d.ts +2 -1
  24. package/dist/repo-store/index.js +1 -0
  25. package/dist/repo-store/store.d.ts +1 -1
  26. package/dist/repo-store/store.js +138 -1
  27. package/dist/repo-store/subscribe-kind.d.ts +6 -3
  28. package/dist/repo-store/subscribe-kind.js +42 -77
  29. package/dist/repo-store/types.d.ts +94 -6
  30. package/dist/repo-store/user-principal-gate.d.ts +26 -0
  31. package/dist/repo-store/user-principal-gate.js +78 -0
  32. package/dist/session-service.d.ts +252 -126
  33. package/dist/session-service.js +851 -624
  34. package/dist/sidecar-allocation/capability-policy.d.ts +27 -0
  35. package/dist/sidecar-allocation/capability-policy.js +124 -0
  36. package/dist/sidecar-allocation/contracts.d.ts +101 -0
  37. package/dist/sidecar-allocation/contracts.js +26 -0
  38. package/dist/sidecar-allocation/index.d.ts +5 -0
  39. package/dist/sidecar-allocation/index.js +4 -0
  40. package/dist/sidecar-allocation/operation.d.ts +10 -0
  41. package/dist/sidecar-allocation/operation.js +54 -0
  42. package/dist/sidecar-allocation/plugin-registry.d.ts +24 -0
  43. package/dist/sidecar-allocation/plugin-registry.js +61 -0
  44. package/dist/sidecar-allocation/reconciler.d.ts +54 -0
  45. package/dist/sidecar-allocation/reconciler.js +825 -0
  46. package/dist/skill-kind.js +12 -62
  47. package/dist/substrate.d.ts +3 -3
  48. package/dist/substrate.js +1 -1
  49. package/dist/workflow-allocation-service.d.ts +64 -0
  50. package/dist/workflow-allocation-service.js +554 -0
  51. package/dist/workflow-closure-resolution.d.ts +106 -0
  52. package/dist/workflow-closure-resolution.js +123 -0
  53. package/dist/workflow-definition-ensure.d.ts +24 -0
  54. package/dist/workflow-definition-ensure.js +75 -0
  55. package/dist/workflow-dispatch-service.d.ts +42 -0
  56. package/dist/workflow-dispatch-service.js +209 -0
  57. package/dist/workflow-dispatch-settlement.d.ts +29 -0
  58. package/dist/workflow-dispatch-settlement.js +140 -0
  59. package/dist/workflow-kind.d.ts +29 -1
  60. package/dist/workflow-kind.js +143 -139
  61. package/dist/workflow-probe-gate.d.ts +286 -0
  62. package/dist/workflow-probe-gate.js +382 -0
  63. package/dist/workflow-run-kind.d.ts +239 -32
  64. package/dist/workflow-run-kind.js +874 -188
  65. package/dist/workflow-run-reader.d.ts +1 -1
  66. package/dist/workflow-run-reader.js +3 -7
  67. package/dist/workflow-run-restore.d.ts +16 -0
  68. package/dist/workflow-run-restore.js +30 -0
  69. package/dist/workflow-source-closure.d.ts +35 -0
  70. package/dist/workflow-source-closure.js +342 -0
  71. package/dist/workflow-source-pins.d.ts +8 -0
  72. package/dist/workflow-source-pins.js +14 -0
  73. package/dist/ws/index.d.ts +3 -3
  74. package/dist/ws/index.js +2 -2
  75. package/dist/ws/pending-tracker.d.ts +93 -0
  76. package/dist/ws/pending-tracker.js +132 -0
  77. package/dist/ws/sidecar-events.d.ts +139 -37
  78. package/dist/ws/sidecar-events.js +2 -2
  79. package/dist/ws/sidecar-handler.d.ts +218 -60
  80. package/dist/ws/sidecar-handler.js +1713 -732
  81. package/dist/ws/sidecar-handler.test-helpers.d.ts +38 -0
  82. package/dist/ws/sidecar-handler.test-helpers.js +95 -0
  83. package/dist/ws/sidecar-token-authenticator.d.ts +3 -1
  84. package/dist/ws/sidecar-token-authenticator.js +78 -7
  85. package/package.json +14 -13
  86. package/dist/available-skills-stanza.d.ts +0 -21
  87. package/dist/available-skills-stanza.js +0 -32
@@ -0,0 +1,825 @@
1
+ import { type } from "arktype";
2
+ import { sha256 } from "@intx/crypto";
3
+ import { getLogger } from "@intx/log";
4
+ import { hexEncode } from "@intx/types";
5
+ import { SidecarIdentityValidationError } from "../ws/sidecar-handler.js";
6
+ import { SessionLaunchError } from "../session-service.js";
7
+ import { DEFAULT_SIDECAR_ALLOCATION_CONCURRENCY } from "../reconciliation-scheduler.js";
8
+ import { DestroySidecarResult, EnsureSidecarResult, } from "./contracts.js";
9
+ import { DEFAULT_SIDECAR_OPERATION_TIMEOUT_MS, runSidecarOperation, SidecarOperationTimeoutError, } from "./operation.js";
10
+ const logger = getLogger(["hub", "sidecar-allocation"]);
11
+ class ReconciliationLeaseLostError extends Error {
12
+ constructor(allocationId, cause) {
13
+ super(`Reconciliation lease lost for allocation ${allocationId}`, {
14
+ cause,
15
+ });
16
+ this.name = "ReconciliationLeaseLostError";
17
+ }
18
+ }
19
+ const DEFAULT_LEASE_DURATION_MS = 60_000;
20
+ const DEFAULT_CONNECT_TIMEOUT_MS = 120_000;
21
+ const MAX_RETRY_BACKOFF_ATTEMPT = 5;
22
+ function randomHex(bytes) {
23
+ return hexEncode(crypto.getRandomValues(new Uint8Array(bytes)));
24
+ }
25
+ function defaultRetryDelay(attempt) {
26
+ return Math.min(1_000 * 2 ** Math.min(attempt, MAX_RETRY_BACKOFF_ATTEMPT), 30_000);
27
+ }
28
+ function parseEnsureResult(value) {
29
+ const result = EnsureSidecarResult(value);
30
+ if (result instanceof type.errors) {
31
+ throw new Error(`sidecar provisioner returned an invalid ensure result: ${result.summary}`);
32
+ }
33
+ return result;
34
+ }
35
+ function parseDestroyResult(value) {
36
+ const result = DestroySidecarResult(value);
37
+ if (result instanceof type.errors) {
38
+ throw new Error(`sidecar provisioner returned an invalid destroy result: ${result.summary}`);
39
+ }
40
+ return result;
41
+ }
42
+ export function createSidecarAllocationReconciler({ allocationStore, plugins, router, hubWebSocketUrl, onInitializationRecovery, onReady, enableAutomaticReplacementRecovery = false, leaseDurationMs = DEFAULT_LEASE_DURATION_MS, connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS, operationTimeoutMs = DEFAULT_SIDECAR_OPERATION_TIMEOUT_MS, maxConcurrentClaims = DEFAULT_SIDECAR_ALLOCATION_CONCURRENCY, retryDelayMs = defaultRetryDelay, now = () => new Date(), createSidecarId = () => `sc_${randomHex(16)}`, createToken = () => `intx_sc_${randomHex(32)}`, createLeaseId = () => `lease_${randomHex(16)}`, }) {
43
+ if (leaseDurationMs <= 0)
44
+ throw new Error("leaseDurationMs must be positive");
45
+ if (connectTimeoutMs <= 0) {
46
+ throw new Error("connectTimeoutMs must be positive");
47
+ }
48
+ if (!Number.isSafeInteger(operationTimeoutMs) || operationTimeoutMs <= 0) {
49
+ throw new Error("operationTimeoutMs must be a positive integer");
50
+ }
51
+ if (!Number.isSafeInteger(maxConcurrentClaims) || maxConcurrentClaims <= 0) {
52
+ throw new Error("maxConcurrentClaims must be a positive integer");
53
+ }
54
+ const activeAllocations = new Map();
55
+ function trackAllocation(allocation) {
56
+ const active = activeAllocations.get(allocation.id);
57
+ if (active === undefined)
58
+ return;
59
+ if (active.allocation.generation !== allocation.generation)
60
+ active.pendingConnect = null;
61
+ active.allocation = allocation;
62
+ }
63
+ async function finishReconciliation(allocationId, apply) {
64
+ const active = activeAllocations.get(allocationId);
65
+ if (active === undefined)
66
+ throw new ReconciliationLeaseLostError(allocationId);
67
+ const target = { allocationId, generation: active.allocation.generation };
68
+ await queueReconciliationStep(target, async () => {
69
+ active.controller.signal.throwIfAborted();
70
+ await apply(active.pendingConnect?.generation === target.generation &&
71
+ active.allocation.status === "allocated");
72
+ });
73
+ }
74
+ function provisionerFor(allocation) {
75
+ const provisioner = plugins.getProvisioner(allocation.provisionerId);
76
+ if (provisioner === null ||
77
+ provisioner.apiVersion !== allocation.provisionerApiVersion ||
78
+ provisioner.bindingFingerprint !==
79
+ allocation.provisionerBindingFingerprint) {
80
+ return null;
81
+ }
82
+ return provisioner;
83
+ }
84
+ function retryAt(attempt) {
85
+ return new Date(now().getTime() + retryDelayMs(attempt));
86
+ }
87
+ async function withReconciliationLease(allocation, leaseId, operationName, operation, timeoutMs) {
88
+ trackAllocation(allocation);
89
+ const active = activeAllocations.get(allocation.id);
90
+ if (active === undefined)
91
+ throw new ReconciliationLeaseLostError(allocation.id);
92
+ const controller = active.controller;
93
+ try {
94
+ return await runSidecarOperation(operationName, timeoutMs, async (signal) => {
95
+ if (!(await runSidecarOperation("Reconciliation lease validation", operationTimeoutMs, async () => {
96
+ try {
97
+ return await trackAllocationQuery(allocation.id, () => allocationStore.isReconciliationLeaseCurrent(allocation.id, allocation.generation, leaseId));
98
+ }
99
+ catch (cause) {
100
+ throw new ReconciliationLeaseLostError(allocation.id, cause);
101
+ }
102
+ }, signal))) {
103
+ throw new ReconciliationLeaseLostError(allocation.id);
104
+ }
105
+ if (performance.now() >= active.leaseDeadline) {
106
+ controller.abort(new ReconciliationLeaseLostError(allocation.id));
107
+ }
108
+ signal.throwIfAborted();
109
+ return operation({ signal, leaseId });
110
+ }, controller.signal);
111
+ }
112
+ catch (error) {
113
+ controller.signal.throwIfAborted();
114
+ throw error;
115
+ }
116
+ }
117
+ // A timed-out lookup still occupies its database connection. Exclude its
118
+ // allocation until it settles so retries cannot accumulate duplicate reads.
119
+ const pendingAllocationQueries = new Map();
120
+ async function trackAllocationQuery(allocationId, query) {
121
+ const pending = pendingAllocationQueries.get(allocationId) ?? { count: 0 };
122
+ pending.count += 1;
123
+ pendingAllocationQueries.set(allocationId, pending);
124
+ try {
125
+ return await query();
126
+ }
127
+ finally {
128
+ pending.count -= 1;
129
+ if (pending.count === 0)
130
+ pendingAllocationQueries.delete(allocationId);
131
+ }
132
+ }
133
+ async function isSidecarReady(allocation) {
134
+ const active = activeAllocations.get(allocation.id);
135
+ if (active === undefined)
136
+ throw new ReconciliationLeaseLostError(allocation.id);
137
+ return runSidecarOperation("Sidecar readiness", operationTimeoutMs, () => trackAllocationQuery(allocation.id, () => router.isAllocatedSidecarReady({
138
+ allocationId: allocation.id,
139
+ generation: allocation.generation,
140
+ })), active.controller.signal);
141
+ }
142
+ async function replaceAfterFailure(allocation, leaseId, code, message, { onlyIfInitializationIncomplete = false, } = {}) {
143
+ const initializationCheck = onlyIfInitializationIncomplete
144
+ ? {
145
+ onlyIfInitializationIncomplete: true,
146
+ ...(allocation.initializationLeaseId !== undefined
147
+ ? {
148
+ expectedInitializationLeaseId: allocation.initializationLeaseId,
149
+ }
150
+ : {}),
151
+ }
152
+ : {};
153
+ let shouldRetryInitialization = false;
154
+ await queueReconciliationStep({ allocationId: allocation.id, generation: allocation.generation }, async () => {
155
+ const updated = allocation.status === "allocated" &&
156
+ !enableAutomaticReplacementRecovery
157
+ ? await allocationStore.beginUnrecoverableRelease({
158
+ ...initializationCheck,
159
+ allocationId: allocation.id,
160
+ expectedGeneration: allocation.generation,
161
+ expectedLeaseId: leaseId,
162
+ failureCode: code,
163
+ failureMessage: `Automatic recovery is disabled: ${message}`,
164
+ now: now(),
165
+ })
166
+ : await allocationStore.beginReplacement({
167
+ ...initializationCheck,
168
+ allocationId: allocation.id,
169
+ expectedStatus: allocation.status === "allocated"
170
+ ? "allocated"
171
+ : "provisioning",
172
+ expectedGeneration: allocation.generation,
173
+ expectedLeaseId: leaseId,
174
+ nextAttemptAt: retryAt(allocation.ensureAttempts + allocation.destroyAttempts),
175
+ failureCode: code,
176
+ failureMessage: message,
177
+ now: now(),
178
+ });
179
+ if (updated !== null) {
180
+ // A late commit still advances the fence before this queued work
181
+ // settles and its allocation becomes eligible for another claim.
182
+ router.fenceAllocation(updated.id, updated.generation);
183
+ }
184
+ else {
185
+ shouldRetryInitialization = onlyIfInitializationIncomplete;
186
+ }
187
+ });
188
+ if (shouldRetryInitialization)
189
+ await retryInitialization(allocation, leaseId);
190
+ }
191
+ async function retryInitialization(allocation, leaseId) {
192
+ // A publication or unsent rollback may have committed since the claim.
193
+ // Retry the ordinary callback, including initialization and dispatch requeue.
194
+ // If ownership was lost instead, this lease-guarded update changes nothing.
195
+ await finishReconciliation(allocation.id, () => allocationStore.scheduleRetry({
196
+ allocationId: allocation.id,
197
+ expectedStatus: "allocated",
198
+ expectedGeneration: allocation.generation,
199
+ expectedLeaseId: leaseId,
200
+ nextAttemptAt: now(),
201
+ now: now(),
202
+ }));
203
+ }
204
+ async function waitUntilReady(allocation, leaseId, connectionAlreadyReady = false) {
205
+ const target = {
206
+ allocationId: allocation.id,
207
+ generation: allocation.generation,
208
+ };
209
+ const deadline = allocation.connectDeadline;
210
+ const remaining = deadline === undefined ? 0 : deadline.getTime() - now().getTime();
211
+ // A stalled identity query is not evidence that the worker missed its
212
+ // connection deadline. Let it retry without releasing the generation.
213
+ const connectionReady = connectionAlreadyReady || (await isSidecarReady(allocation));
214
+ try {
215
+ if (!connectionReady) {
216
+ await withReconciliationLease(allocation, leaseId, "Sidecar connection", () => trackAllocationQuery(allocation.id, () => router.waitForAllocatedSidecar(target, Math.max(0, remaining), (validation) => {
217
+ // The waiter can expire before a notification lookup settles.
218
+ // Retain its exclusion and capacity independently of the wait.
219
+ void trackAllocationQuery(allocation.id, () => validation).catch(() => undefined);
220
+ })), operationTimeoutMs);
221
+ }
222
+ }
223
+ catch (error) {
224
+ // Let the router report connection expiry. Our outer deadline can expire
225
+ // during lease or identity validation without establishing worker loss.
226
+ // A failed identity lookup is likewise inconclusive: the worker may be
227
+ // healthy behind it, so retry instead of releasing the generation.
228
+ if (error instanceof ReconciliationLeaseLostError ||
229
+ error instanceof SidecarOperationTimeoutError ||
230
+ error instanceof SidecarIdentityValidationError)
231
+ throw error;
232
+ await replaceAfterFailure(allocation, leaseId, "sidecar_connect_failed", error instanceof Error ? error.message : String(error));
233
+ return;
234
+ }
235
+ await queueReconciliationStep(target, async () => {
236
+ const active = activeAllocations.get(allocation.id);
237
+ if (active?.allocation.generation !== allocation.generation)
238
+ throw new ReconciliationLeaseLostError(allocation.id);
239
+ active.controller.signal.throwIfAborted();
240
+ active.pendingConnect = null;
241
+ });
242
+ if (onReady !== undefined) {
243
+ try {
244
+ await withReconciliationLease(allocation, leaseId, "Workflow initialization", (context) => trackAllocationQuery(allocation.id, () => onReady(allocation, context)));
245
+ }
246
+ catch (error) {
247
+ if (error instanceof ReconciliationLeaseLostError)
248
+ throw error;
249
+ if (error instanceof SessionLaunchError && error.leakedAgent) {
250
+ await replaceAfterFailure(allocation, leaseId, "sidecar_initialization_uncertain", error.message, { onlyIfInitializationIncomplete: true });
251
+ return;
252
+ }
253
+ await finishReconciliation(allocation.id, () => allocationStore.scheduleRetry({
254
+ allocationId: allocation.id,
255
+ expectedStatus: "allocated",
256
+ expectedGeneration: allocation.generation,
257
+ // Initialization has no separate durable attempt counter. Use the
258
+ // capped delay so a persistent launch error cannot create a hot loop.
259
+ nextAttemptAt: retryAt(MAX_RETRY_BACKOFF_ATTEMPT),
260
+ expectedLeaseId: leaseId,
261
+ failure: {
262
+ code: "sidecar_initialization_failed",
263
+ message: error instanceof Error ? error.message : String(error),
264
+ },
265
+ now: now(),
266
+ }));
267
+ return;
268
+ }
269
+ }
270
+ // A connect that lands during initialization schedules an immediate
271
+ // follow-up even on success: the new socket may be a restarted worker
272
+ // with an empty inventory (takeover suppresses the disconnect event), in
273
+ // which case the follow-up redeploys and restores it. When the worker is
274
+ // unchanged the follow-up is a no-op: deployReadyAllocation returns early
275
+ // once the workflow is active and its key is recorded.
276
+ await finishReconciliation(allocation.id, (pendingConnect) => pendingConnect
277
+ ? allocationStore.scheduleRetry({
278
+ allocationId: allocation.id,
279
+ expectedStatus: "allocated",
280
+ expectedGeneration: allocation.generation,
281
+ expectedLeaseId: leaseId,
282
+ nextAttemptAt: now(),
283
+ now: now(),
284
+ })
285
+ : allocationStore.markConnectionReady({
286
+ allocationId: allocation.id,
287
+ generation: allocation.generation,
288
+ expectedLeaseId: leaseId,
289
+ now: now(),
290
+ }));
291
+ }
292
+ async function acceptEnsure(allocation, leaseId, provisioner, token) {
293
+ if (allocation.sidecarId === undefined) {
294
+ throw new Error(`Allocation ${allocation.id} has no sidecar identity`);
295
+ }
296
+ const sidecarId = allocation.sidecarId;
297
+ let result;
298
+ try {
299
+ result = parseEnsureResult(await withReconciliationLease(allocation, leaseId, "Sidecar ensure", ({ signal }) => provisioner.ensure({
300
+ signal,
301
+ allocationId: allocation.id,
302
+ generation: allocation.generation,
303
+ tenantId: allocation.tenantId,
304
+ anchorRunId: allocation.anchorRunId,
305
+ sidecarId,
306
+ token,
307
+ hubWebSocketUrl,
308
+ }), operationTimeoutMs));
309
+ }
310
+ catch (error) {
311
+ if (error instanceof ReconciliationLeaseLostError)
312
+ throw error;
313
+ await replaceAfterFailure(allocation, leaseId, "ensure_failed", error instanceof Error ? error.message : String(error));
314
+ return;
315
+ }
316
+ if (result.kind === "rejected") {
317
+ if (!result.retryable) {
318
+ const failed = await allocationStore.failWithoutInfrastructure({
319
+ allocationId: allocation.id,
320
+ expectedStatus: "provisioning",
321
+ expectedGeneration: allocation.generation,
322
+ code: result.code,
323
+ message: result.message,
324
+ expectedLeaseId: leaseId,
325
+ now: now(),
326
+ });
327
+ if (failed !== null) {
328
+ router.retireAllocation({
329
+ allocationId: failed.id,
330
+ generation: failed.generation,
331
+ });
332
+ }
333
+ return;
334
+ }
335
+ await replaceAfterFailure(allocation, leaseId, result.code, result.message);
336
+ return;
337
+ }
338
+ const allocated = await allocationStore.markAllocated({
339
+ allocationId: allocation.id,
340
+ generation: allocation.generation,
341
+ ...(result.externalRef !== undefined
342
+ ? { externalRef: result.externalRef }
343
+ : {}),
344
+ expectedLeaseId: leaseId,
345
+ now: now(),
346
+ });
347
+ if (allocated !== null) {
348
+ trackAllocation(allocated);
349
+ if (await isSidecarReady(allocated)) {
350
+ await waitUntilReady(allocated, leaseId, true);
351
+ return;
352
+ }
353
+ // Provisioning acceptance and websocket readiness are separate durable
354
+ // transitions. Do not hold the single reconciliation loop for the full
355
+ // connection timeout: park this lease at its persisted deadline and let
356
+ // sidecar.allocated.connected wake it immediately when the worker arrives.
357
+ await finishReconciliation(allocated.id, (pendingConnect) => pendingConnect
358
+ ? allocationStore.scheduleRetry({
359
+ allocationId: allocated.id,
360
+ expectedStatus: "allocated",
361
+ expectedGeneration: allocated.generation,
362
+ expectedLeaseId: leaseId,
363
+ nextAttemptAt: now(),
364
+ now: now(),
365
+ })
366
+ : allocationStore.parkReconciliation(allocated.id, leaseId, {
367
+ kind: "await-connection",
368
+ fallbackNextAttemptAt: retryAt(MAX_RETRY_BACKOFF_ATTEMPT),
369
+ }));
370
+ }
371
+ }
372
+ async function bindAndEnsure(allocation, leaseId, provisioner, replacement) {
373
+ const token = createToken();
374
+ const sidecarId = createSidecarId();
375
+ const connectDeadline = new Date(now().getTime() + connectTimeoutMs);
376
+ const bound = replacement
377
+ ? await allocationStore.bindReplacementSidecar({
378
+ allocationId: allocation.id,
379
+ generation: allocation.generation,
380
+ sidecarId,
381
+ tokenHashSha256: await sha256(token),
382
+ connectDeadline,
383
+ expectedLeaseId: leaseId,
384
+ now: now(),
385
+ })
386
+ : await allocationStore.bindInitialSidecar({
387
+ allocationId: allocation.id,
388
+ expectedGeneration: allocation.generation,
389
+ sidecarId,
390
+ tokenHashSha256: await sha256(token),
391
+ connectDeadline,
392
+ expectedLeaseId: leaseId,
393
+ now: now(),
394
+ });
395
+ if (bound === null)
396
+ return;
397
+ trackAllocation(bound);
398
+ router.fenceAllocation(bound.id, bound.generation);
399
+ await acceptEnsure(bound, leaseId, provisioner, token);
400
+ }
401
+ async function retryDestroy(allocation, leaseId) {
402
+ if (allocation.status !== "replacing" &&
403
+ allocation.status !== "releasing") {
404
+ throw new Error(`Cannot retry destroy while allocation ${allocation.id} is ${allocation.status}`);
405
+ }
406
+ const status = allocation.status;
407
+ await finishReconciliation(allocation.id, () => allocationStore.scheduleRetry({
408
+ allocationId: allocation.id,
409
+ expectedStatus: status,
410
+ expectedGeneration: allocation.generation,
411
+ nextAttemptAt: retryAt(allocation.destroyAttempts),
412
+ expectedLeaseId: leaseId,
413
+ attempt: "destroy",
414
+ now: now(),
415
+ }));
416
+ }
417
+ async function destroyCurrent(allocation, leaseId, provisioner) {
418
+ if (allocation.sidecarId === undefined)
419
+ return true;
420
+ const sidecarId = allocation.sidecarId;
421
+ let result;
422
+ try {
423
+ result = parseDestroyResult(await withReconciliationLease(allocation, leaseId, "Sidecar destroy", ({ signal }) => provisioner.destroy({
424
+ signal,
425
+ allocationId: allocation.id,
426
+ generation: allocation.generation,
427
+ sidecarId,
428
+ ...(allocation.externalRef !== undefined
429
+ ? { externalRef: allocation.externalRef }
430
+ : {}),
431
+ }), operationTimeoutMs));
432
+ }
433
+ catch (error) {
434
+ if (error instanceof ReconciliationLeaseLostError)
435
+ throw error;
436
+ logger.warn `Destroy failed for allocation ${allocation.id}: ${error instanceof Error ? error.message : String(error)}`;
437
+ await retryDestroy(allocation, leaseId);
438
+ return false;
439
+ }
440
+ if (result.kind === "destroyed")
441
+ return true;
442
+ if (!result.retryable) {
443
+ const failed = await allocationStore.markDestroyFailed({
444
+ allocationId: allocation.id,
445
+ expectedGeneration: allocation.generation,
446
+ expectedLeaseId: leaseId,
447
+ code: result.code,
448
+ message: result.message,
449
+ now: now(),
450
+ });
451
+ if (failed !== null) {
452
+ router.retireAllocation({
453
+ allocationId: failed.id,
454
+ generation: failed.generation,
455
+ });
456
+ }
457
+ return false;
458
+ }
459
+ await retryDestroy(allocation, leaseId);
460
+ return false;
461
+ }
462
+ async function reconcile(allocation, leaseId) {
463
+ router.fenceAllocation(allocation.id, allocation.generation);
464
+ if (allocation.status === "released" ||
465
+ allocation.status === "failed" ||
466
+ allocation.status === "destroy_failed") {
467
+ return;
468
+ }
469
+ if (allocation.status === "allocated" &&
470
+ onInitializationRecovery !== undefined) {
471
+ await withReconciliationLease(allocation, leaseId, "Sender deployment recovery", (context) => trackAllocationQuery(allocation.id, () => onInitializationRecovery(allocation, context)), operationTimeoutMs);
472
+ }
473
+ const provisioner = provisionerFor(allocation);
474
+ if (provisioner === null) {
475
+ if (allocation.status === "pending") {
476
+ const failed = await allocationStore.failWithoutInfrastructure({
477
+ allocationId: allocation.id,
478
+ expectedStatus: "pending",
479
+ expectedGeneration: allocation.generation,
480
+ code: "provisioner_unavailable",
481
+ message: `Provisioner ${allocation.provisionerId} is unavailable or its binding changed`,
482
+ expectedLeaseId: leaseId,
483
+ now: now(),
484
+ });
485
+ if (failed !== null) {
486
+ router.retireAllocation({
487
+ allocationId: failed.id,
488
+ generation: failed.generation,
489
+ });
490
+ }
491
+ }
492
+ else {
493
+ const status = allocation.status;
494
+ await finishReconciliation(allocation.id, () => allocationStore.scheduleRetry({
495
+ allocationId: allocation.id,
496
+ expectedStatus: status,
497
+ expectedGeneration: allocation.generation,
498
+ nextAttemptAt: retryAt(allocation.ensureAttempts + allocation.destroyAttempts),
499
+ expectedLeaseId: leaseId,
500
+ now: now(),
501
+ }));
502
+ }
503
+ return;
504
+ }
505
+ switch (allocation.status) {
506
+ case "pending":
507
+ await bindAndEnsure(allocation, leaseId, provisioner, false);
508
+ return;
509
+ case "provisioning":
510
+ // The raw bearer token is deliberately not durable. Re-entering this
511
+ // state means the process died before ensure acceptance was recorded.
512
+ await replaceAfterFailure(allocation, leaseId, "ensure_outcome_unknown", "Hub restarted before sidecar provisioning acceptance was recorded");
513
+ return;
514
+ case "allocated":
515
+ if (allocation.initializationLeaseId !== undefined) {
516
+ await replaceAfterFailure(allocation, leaseId, "sidecar_initialization_uncertain", "A previous initialization attempt did not record completion", { onlyIfInitializationIncomplete: true });
517
+ return;
518
+ }
519
+ await waitUntilReady(allocation, leaseId);
520
+ return;
521
+ case "replacing":
522
+ if (!(await destroyCurrent(allocation, leaseId, provisioner)))
523
+ return;
524
+ await bindAndEnsure(allocation, leaseId, provisioner, true);
525
+ return;
526
+ case "releasing": {
527
+ if (!(await destroyCurrent(allocation, leaseId, provisioner)))
528
+ return;
529
+ const released = await allocationStore.markReleased({
530
+ allocationId: allocation.id,
531
+ generation: allocation.generation,
532
+ expectedLeaseId: leaseId,
533
+ now: now(),
534
+ });
535
+ if (released !== null) {
536
+ router.retireAllocation({
537
+ allocationId: released.id,
538
+ generation: released.generation,
539
+ });
540
+ }
541
+ return;
542
+ }
543
+ default: {
544
+ const exhaustive = allocation.status;
545
+ throw new Error(`Allocation ${allocation.id} has unhandled status ${String(exhaustive)}`);
546
+ }
547
+ }
548
+ }
549
+ async function initialize() {
550
+ for (const allocation of await allocationStore.listActive()) {
551
+ router.fenceAllocation(allocation.id, allocation.generation);
552
+ if (allocation.status === "allocated") {
553
+ await allocationStore.markConnectionLost({
554
+ allocationId: allocation.id,
555
+ generation: allocation.generation,
556
+ connectDeadline: allocation.connectDeadline ??
557
+ new Date(now().getTime() + connectTimeoutMs),
558
+ now: now(),
559
+ });
560
+ }
561
+ else if (allocation.nextAttemptAt === undefined) {
562
+ // Scheduled retries are durable state. Only repair an unscheduled
563
+ // active row; moving an existing deadline earlier would erase provider
564
+ // backoff whenever the Hub restarts.
565
+ await allocationStore.wakeReconciliation(allocation.id, allocation.generation);
566
+ }
567
+ }
568
+ }
569
+ const connectionEvents = new Map();
570
+ function queueConnectionEvent(target, apply) {
571
+ const previous = connectionEvents.get(target.allocationId) ?? Promise.resolve();
572
+ const pending = previous.catch(() => undefined).then(apply);
573
+ connectionEvents.set(target.allocationId, pending);
574
+ const settled = () => {
575
+ if (connectionEvents.get(target.allocationId) === pending)
576
+ connectionEvents.delete(target.allocationId);
577
+ };
578
+ void pending.then(settled, settled);
579
+ return pending;
580
+ }
581
+ async function queueReconciliationStep(target, apply) {
582
+ const active = activeAllocations.get(target.allocationId);
583
+ if (active?.allocation.generation !== target.generation)
584
+ throw new ReconciliationLeaseLostError(target.allocationId);
585
+ try {
586
+ await runSidecarOperation("Allocation connection events", operationTimeoutMs, (signal) => queueConnectionEvent(target, async () => {
587
+ signal.throwIfAborted();
588
+ await apply();
589
+ }), active.controller.signal);
590
+ }
591
+ catch (error) {
592
+ if (error instanceof SidecarOperationTimeoutError) {
593
+ // Stop this lease without queuing another write behind the same stalled
594
+ // event. Keep the actual operation queued until it settles, preserving
595
+ // ordering with reconnects and excluding this allocation from new claims.
596
+ const cancelled = new ReconciliationLeaseLostError(target.allocationId, error);
597
+ active.controller.abort(cancelled);
598
+ throw cancelled;
599
+ }
600
+ throw error;
601
+ }
602
+ }
603
+ function noteDisconnect(target, leaseInvalidated = false) {
604
+ const active = activeAllocations.get(target.allocationId);
605
+ if (active?.allocation.generation !== target.generation)
606
+ return;
607
+ active.pendingConnect = null;
608
+ if (leaseInvalidated || active.allocation.status === "allocated") {
609
+ active.controller.abort(new ReconciliationLeaseLostError(target.allocationId));
610
+ }
611
+ }
612
+ function noteConnect(target) {
613
+ const active = activeAllocations.get(target.allocationId);
614
+ if (active?.allocation.generation === target.generation)
615
+ active.pendingConnect = target;
616
+ }
617
+ function handleDisconnect(target) {
618
+ noteDisconnect(target);
619
+ return queueConnectionEvent(target, async () => {
620
+ const disconnected = await allocationStore.markConnectionLost({
621
+ allocationId: target.allocationId,
622
+ generation: target.generation,
623
+ connectDeadline: new Date(now().getTime() + connectTimeoutMs),
624
+ now: now(),
625
+ });
626
+ noteDisconnect(target, disconnected !== null);
627
+ });
628
+ }
629
+ function handleConnected(target) {
630
+ noteConnect(target);
631
+ return queueConnectionEvent(target, async () => {
632
+ await allocationStore.wakeReconciliation(target.allocationId, target.generation);
633
+ noteConnect(target);
634
+ });
635
+ }
636
+ async function repairUnscheduledConnections() {
637
+ for (const allocation of await allocationStore.listActive()) {
638
+ if (allocation.status !== "allocated" ||
639
+ allocation.nextAttemptAt !== undefined ||
640
+ allocation.reconciliationLeaseId !== undefined ||
641
+ allocation.reconciliationLeaseExpiresAt !== undefined) {
642
+ continue;
643
+ }
644
+ const target = {
645
+ allocationId: allocation.id,
646
+ generation: allocation.generation,
647
+ };
648
+ try {
649
+ if (await router.isAllocatedSidecarReady(target))
650
+ continue;
651
+ }
652
+ catch (error) {
653
+ // Unknown readiness is not absence. Leave the allocation for the next
654
+ // repair sweep instead of scheduling a reconnect the worker may hold.
655
+ if (error instanceof SidecarIdentityValidationError)
656
+ continue;
657
+ throw error;
658
+ }
659
+ try {
660
+ await allocationStore.scheduleReconnectIfUnscheduled({
661
+ ...target,
662
+ connectDeadline: allocation.connectDeadline ??
663
+ new Date(now().getTime() + connectTimeoutMs),
664
+ now: now(),
665
+ });
666
+ }
667
+ catch (error) {
668
+ logger.warn `Failed to repair allocation ${allocation.id} reconnect schedule: ${error instanceof Error ? error.message : String(error)}`;
669
+ }
670
+ }
671
+ }
672
+ let admittedClaims = 0;
673
+ async function reconcileNext() {
674
+ const retainedAllocations = new Set([
675
+ ...connectionEvents.keys(),
676
+ ...pendingAllocationQueries.keys(),
677
+ ]);
678
+ // An active claim already owns capacity for its allocation's pending work.
679
+ for (const allocationId of activeAllocations.keys())
680
+ retainedAllocations.delete(allocationId);
681
+ if (admittedClaims + retainedAllocations.size >= maxConcurrentClaims)
682
+ return false;
683
+ admittedClaims += 1;
684
+ let claim;
685
+ try {
686
+ const leaseId = createLeaseId();
687
+ const claimStartedAt = performance.now();
688
+ const allocation = await runSidecarOperation("Sidecar allocation claim", operationTimeoutMs, () => {
689
+ claim = allocationStore.claimNextReconcilable({
690
+ leaseId,
691
+ leaseDurationMs,
692
+ excludedAllocationIds: [
693
+ ...new Set([
694
+ ...activeAllocations.keys(),
695
+ ...connectionEvents.keys(),
696
+ ...pendingAllocationQueries.keys(),
697
+ ]),
698
+ ],
699
+ });
700
+ return claim;
701
+ });
702
+ if (allocation === null)
703
+ return false;
704
+ return await reconcileClaim(allocation, leaseId, claimStartedAt);
705
+ }
706
+ finally {
707
+ const release = () => {
708
+ admittedClaims -= 1;
709
+ };
710
+ // Keep the reservation through the claim-to-reconciliation handoff. A
711
+ // timed-out claim still owns capacity until its database query settles.
712
+ if (claim === undefined)
713
+ release();
714
+ else
715
+ void claim.then(release, release);
716
+ }
717
+ }
718
+ async function reconcileClaim(allocation, leaseId, claimStartedAt) {
719
+ // Local work may have started since this claim's exclusion snapshot. Let
720
+ // its lease expire without adding another database write behind that work.
721
+ if (connectionEvents.has(allocation.id) ||
722
+ activeAllocations.has(allocation.id) ||
723
+ pendingAllocationQueries.has(allocation.id))
724
+ return true;
725
+ const active = {
726
+ allocation,
727
+ controller: new AbortController(),
728
+ leaseDeadline: claimStartedAt + leaseDurationMs,
729
+ pendingConnect: null,
730
+ };
731
+ activeAllocations.set(allocation.id, active);
732
+ let finished = false;
733
+ let renewing = false;
734
+ const renew = async () => {
735
+ if (finished || renewing || active.controller.signal.aborted)
736
+ return;
737
+ renewing = true;
738
+ const startedAt = performance.now();
739
+ try {
740
+ const renewed = await trackAllocationQuery(allocation.id, () => allocationStore.extendReconciliationLease(allocation.id, leaseId, leaseDurationMs));
741
+ if (finished || active.controller.signal.aborted)
742
+ return;
743
+ if (!renewed || performance.now() >= active.leaseDeadline) {
744
+ active.controller.abort(new ReconciliationLeaseLostError(allocation.id));
745
+ }
746
+ else {
747
+ // The database grants the lease during the request. Counting from its
748
+ // start avoids extending ownership by the response's transit time.
749
+ active.leaseDeadline = startedAt + leaseDurationMs;
750
+ }
751
+ }
752
+ catch (error) {
753
+ if (!finished && !active.controller.signal.aborted) {
754
+ active.controller.abort(new ReconciliationLeaseLostError(allocation.id, error));
755
+ }
756
+ }
757
+ finally {
758
+ renewing = false;
759
+ }
760
+ };
761
+ // One heartbeat covers the whole claim, including database transitions
762
+ // between provider calls. Short stages must not keep postponing renewal.
763
+ const heartbeat = setInterval(() => {
764
+ void renew();
765
+ }, Math.max(1, Math.floor(leaseDurationMs / 3)));
766
+ let expiryTimer;
767
+ const checkLeaseExpiry = () => {
768
+ if (active.controller.signal.aborted)
769
+ return;
770
+ const remaining = active.leaseDeadline - performance.now();
771
+ if (remaining <= 0) {
772
+ active.controller.abort(new ReconciliationLeaseLostError(allocation.id));
773
+ }
774
+ else {
775
+ expiryTimer = setTimeout(checkLeaseExpiry, Math.ceil(remaining));
776
+ }
777
+ };
778
+ checkLeaseExpiry();
779
+ try {
780
+ active.controller.signal.throwIfAborted();
781
+ await reconcile(allocation, leaseId);
782
+ }
783
+ catch (error) {
784
+ if (error instanceof ReconciliationLeaseLostError) {
785
+ const cause = error.cause;
786
+ if (cause === undefined) {
787
+ logger.info `Allocation ${allocation.id} reconciliation stopped: lease ${leaseId} is no longer current`;
788
+ }
789
+ else {
790
+ logger.warn `Allocation ${allocation.id} reconciliation stopped because lease ${leaseId} could not be confirmed: ${cause instanceof Error ? cause.message : String(cause)}`;
791
+ }
792
+ // The durable schedule survives the claim. Stop renewing and let the
793
+ // lease expire; handling a lease failure must not require another write.
794
+ return true;
795
+ }
796
+ logger.error `Allocation ${allocation.id} reconciliation failed: ${error instanceof Error ? error.message : String(error)}`;
797
+ await finishReconciliation(allocation.id, () => allocationStore.parkReconciliation(allocation.id, leaseId, {
798
+ kind: "retry-after-error",
799
+ notBefore: retryAt(MAX_RETRY_BACKOFF_ATTEMPT),
800
+ }));
801
+ }
802
+ finally {
803
+ finished = true;
804
+ clearInterval(heartbeat);
805
+ clearTimeout(expiryTimer);
806
+ activeAllocations.delete(allocation.id);
807
+ }
808
+ return true;
809
+ }
810
+ async function reconcileUntilIdle(maxIterations = 100) {
811
+ let reconciled = 0;
812
+ while (reconciled < maxIterations && (await reconcileNext())) {
813
+ reconciled += 1;
814
+ }
815
+ return reconciled;
816
+ }
817
+ return {
818
+ initialize,
819
+ handleDisconnect,
820
+ handleConnected,
821
+ repairUnscheduledConnections,
822
+ reconcileNext,
823
+ reconcileUntilIdle,
824
+ };
825
+ }