@intx/hub-sessions 0.3.0 → 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 (67) hide show
  1. package/dist/agent-repo.d.ts +14 -2
  2. package/dist/agent-repo.js +17 -4
  3. package/dist/agent-state-kind.js +14 -63
  4. package/dist/asset-service.js +14 -10
  5. package/dist/credential-push.d.ts +48 -4
  6. package/dist/credential-push.js +138 -6
  7. package/dist/event-collector-registry.d.ts +2 -1
  8. package/dist/event-collector-registry.js +38 -9
  9. package/dist/event-collector.d.ts +11 -1
  10. package/dist/event-collector.js +36 -3
  11. package/dist/hub-session-lookups.d.ts +1 -1
  12. package/dist/hub-session-lookups.js +68 -72
  13. package/dist/hub-session-orchestrator.d.ts +2 -3
  14. package/dist/hub-session-orchestrator.js +13 -12
  15. package/dist/index.d.ts +7 -6
  16. package/dist/index.js +7 -6
  17. package/dist/reconciliation-scheduler.d.ts +14 -0
  18. package/dist/reconciliation-scheduler.js +55 -0
  19. package/dist/repo-store/index.d.ts +1 -0
  20. package/dist/repo-store/index.js +1 -0
  21. package/dist/repo-store/user-principal-gate.d.ts +26 -0
  22. package/dist/repo-store/user-principal-gate.js +78 -0
  23. package/dist/session-service.d.ts +66 -121
  24. package/dist/session-service.js +444 -411
  25. package/dist/sidecar-allocation/capability-policy.d.ts +27 -0
  26. package/dist/sidecar-allocation/capability-policy.js +124 -0
  27. package/dist/sidecar-allocation/contracts.d.ts +29 -6
  28. package/dist/sidecar-allocation/contracts.js +7 -2
  29. package/dist/sidecar-allocation/index.d.ts +4 -3
  30. package/dist/sidecar-allocation/index.js +3 -2
  31. package/dist/sidecar-allocation/operation.d.ts +10 -0
  32. package/dist/sidecar-allocation/operation.js +54 -0
  33. package/dist/sidecar-allocation/plugin-registry.d.ts +16 -3
  34. package/dist/sidecar-allocation/plugin-registry.js +36 -12
  35. package/dist/sidecar-allocation/reconciler.d.ts +16 -4
  36. package/dist/sidecar-allocation/reconciler.js +486 -92
  37. package/dist/skill-kind.js +8 -62
  38. package/dist/substrate.d.ts +1 -1
  39. package/dist/substrate.js +1 -1
  40. package/dist/workflow-allocation-service.d.ts +21 -15
  41. package/dist/workflow-allocation-service.js +440 -125
  42. package/dist/workflow-dispatch-service.d.ts +4 -2
  43. package/dist/workflow-dispatch-service.js +89 -26
  44. package/dist/workflow-kind.d.ts +12 -0
  45. package/dist/workflow-kind.js +17 -60
  46. package/dist/workflow-probe-gate.d.ts +99 -27
  47. package/dist/workflow-probe-gate.js +196 -21
  48. package/dist/workflow-run-kind.d.ts +112 -19
  49. package/dist/workflow-run-kind.js +626 -210
  50. package/dist/workflow-run-restore.d.ts +1 -0
  51. package/dist/workflow-run-restore.js +5 -1
  52. package/dist/workflow-source-pins.d.ts +8 -0
  53. package/dist/workflow-source-pins.js +14 -0
  54. package/dist/ws/index.d.ts +1 -1
  55. package/dist/ws/index.js +1 -1
  56. package/dist/ws/pending-tracker.d.ts +93 -0
  57. package/dist/ws/pending-tracker.js +132 -0
  58. package/dist/ws/sidecar-events.d.ts +43 -29
  59. package/dist/ws/sidecar-events.js +0 -2
  60. package/dist/ws/sidecar-handler.d.ts +122 -85
  61. package/dist/ws/sidecar-handler.js +925 -878
  62. package/dist/ws/sidecar-handler.test-helpers.d.ts +38 -0
  63. package/dist/ws/sidecar-handler.test-helpers.js +95 -0
  64. package/dist/ws/sidecar-token-authenticator.js +37 -23
  65. package/package.json +13 -13
  66. package/dist/sidecar-allocation/placement-policy.d.ts +0 -11
  67. package/dist/sidecar-allocation/placement-policy.js +0 -21
@@ -2,9 +2,20 @@ import { type } from "arktype";
2
2
  import { sha256 } from "@intx/crypto";
3
3
  import { getLogger } from "@intx/log";
4
4
  import { hexEncode } from "@intx/types";
5
+ import { SidecarIdentityValidationError } from "../ws/sidecar-handler.js";
5
6
  import { SessionLaunchError } from "../session-service.js";
7
+ import { DEFAULT_SIDECAR_ALLOCATION_CONCURRENCY } from "../reconciliation-scheduler.js";
6
8
  import { DestroySidecarResult, EnsureSidecarResult, } from "./contracts.js";
9
+ import { DEFAULT_SIDECAR_OPERATION_TIMEOUT_MS, runSidecarOperation, SidecarOperationTimeoutError, } from "./operation.js";
7
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
+ }
8
19
  const DEFAULT_LEASE_DURATION_MS = 60_000;
9
20
  const DEFAULT_CONNECT_TIMEOUT_MS = 120_000;
10
21
  const MAX_RETRY_BACKOFF_ATTEMPT = 5;
@@ -28,12 +39,38 @@ function parseDestroyResult(value) {
28
39
  }
29
40
  return result;
30
41
  }
31
- export function createSidecarAllocationReconciler({ allocationStore, plugins, router, hubWebSocketUrl, onReady, enableAutomaticReplacementRecovery = false, leaseDurationMs = DEFAULT_LEASE_DURATION_MS, connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS, retryDelayMs = defaultRetryDelay, now = () => new Date(), createSidecarId = () => `sc_${randomHex(16)}`, createToken = () => `intx_sc_${randomHex(32)}`, createLeaseId = () => `lease_${randomHex(16)}`, }) {
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)}`, }) {
32
43
  if (leaseDurationMs <= 0)
33
44
  throw new Error("leaseDurationMs must be positive");
34
45
  if (connectTimeoutMs <= 0) {
35
46
  throw new Error("connectTimeoutMs must be positive");
36
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
+ }
37
74
  function provisionerFor(allocation) {
38
75
  const provisioner = plugins.getProvisioner(allocation.provisionerId);
39
76
  if (provisioner === null ||
@@ -47,50 +84,122 @@ export function createSidecarAllocationReconciler({ allocationStore, plugins, ro
47
84
  function retryAt(attempt) {
48
85
  return new Date(now().getTime() + retryDelayMs(attempt));
49
86
  }
50
- async function withLeaseHeartbeat(allocation, leaseId, operation) {
51
- const interval = setInterval(() => {
52
- void allocationStore
53
- .extendReconciliationLease(allocation.id, leaseId, leaseDurationMs)
54
- .catch((error) => {
55
- logger.warn `Failed to extend allocation ${allocation.id} lease: ${error instanceof Error ? error.message : String(error)}`;
56
- });
57
- }, Math.max(1, Math.floor(leaseDurationMs / 3)));
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;
58
93
  try {
59
- return await operation();
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();
60
126
  }
61
127
  finally {
62
- clearInterval(interval);
128
+ pending.count -= 1;
129
+ if (pending.count === 0)
130
+ pendingAllocationQueries.delete(allocationId);
63
131
  }
64
132
  }
65
- async function replaceAfterFailure(allocation, leaseId, code, message) {
66
- if (allocation.status === "allocated" &&
67
- !enableAutomaticReplacementRecovery) {
68
- const releasing = await allocationStore.beginUnrecoverableRelease({
69
- allocationId: allocation.id,
70
- expectedGeneration: allocation.generation,
71
- expectedLeaseId: leaseId,
72
- failureCode: code,
73
- failureMessage: `Automatic recovery is disabled: ${message}`,
74
- now: now(),
75
- });
76
- if (releasing !== null) {
77
- router.fenceAllocation(releasing.id, releasing.generation);
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
+ : {}),
78
151
  }
79
- return;
80
- }
81
- const replaced = await allocationStore.beginReplacement({
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({
82
196
  allocationId: allocation.id,
83
- expectedStatus: allocation.status === "allocated" ? "allocated" : "provisioning",
197
+ expectedStatus: "allocated",
84
198
  expectedGeneration: allocation.generation,
85
199
  expectedLeaseId: leaseId,
86
- nextAttemptAt: retryAt(allocation.ensureAttempts + allocation.destroyAttempts),
87
- failureCode: code,
88
- failureMessage: message,
200
+ nextAttemptAt: now(),
89
201
  now: now(),
90
- });
91
- if (replaced !== null) {
92
- router.fenceAllocation(replaced.id, replaced.generation);
93
- }
202
+ }));
94
203
  }
95
204
  async function waitUntilReady(allocation, leaseId, connectionAlreadyReady = false) {
96
205
  const target = {
@@ -99,26 +208,49 @@ export function createSidecarAllocationReconciler({ allocationStore, plugins, ro
99
208
  };
100
209
  const deadline = allocation.connectDeadline;
101
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));
102
214
  try {
103
- if (!connectionAlreadyReady &&
104
- !(await router.isAllocatedSidecarReady(target))) {
105
- await withLeaseHeartbeat(allocation, leaseId, () => router.waitForAllocatedSidecar(target, Math.max(0, remaining)));
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);
106
221
  }
107
222
  }
108
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;
109
232
  await replaceAfterFailure(allocation, leaseId, "sidecar_connect_failed", error instanceof Error ? error.message : String(error));
110
233
  return;
111
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
+ });
112
242
  if (onReady !== undefined) {
113
243
  try {
114
- await withLeaseHeartbeat(allocation, leaseId, () => onReady(allocation));
244
+ await withReconciliationLease(allocation, leaseId, "Workflow initialization", (context) => trackAllocationQuery(allocation.id, () => onReady(allocation, context)));
115
245
  }
116
246
  catch (error) {
247
+ if (error instanceof ReconciliationLeaseLostError)
248
+ throw error;
117
249
  if (error instanceof SessionLaunchError && error.leakedAgent) {
118
- await replaceAfterFailure(allocation, leaseId, "sidecar_initialization_uncertain", error.message);
250
+ await replaceAfterFailure(allocation, leaseId, "sidecar_initialization_uncertain", error.message, { onlyIfInitializationIncomplete: true });
119
251
  return;
120
252
  }
121
- await allocationStore.scheduleRetry({
253
+ await finishReconciliation(allocation.id, () => allocationStore.scheduleRetry({
122
254
  allocationId: allocation.id,
123
255
  expectedStatus: "allocated",
124
256
  expectedGeneration: allocation.generation,
@@ -131,16 +263,31 @@ export function createSidecarAllocationReconciler({ allocationStore, plugins, ro
131
263
  message: error instanceof Error ? error.message : String(error),
132
264
  },
133
265
  now: now(),
134
- });
266
+ }));
135
267
  return;
136
268
  }
137
269
  }
138
- await allocationStore.markConnectionReady({
139
- allocationId: allocation.id,
140
- generation: allocation.generation,
141
- expectedLeaseId: leaseId,
142
- now: now(),
143
- });
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
+ }));
144
291
  }
145
292
  async function acceptEnsure(allocation, leaseId, provisioner, token) {
146
293
  if (allocation.sidecarId === undefined) {
@@ -149,7 +296,8 @@ export function createSidecarAllocationReconciler({ allocationStore, plugins, ro
149
296
  const sidecarId = allocation.sidecarId;
150
297
  let result;
151
298
  try {
152
- result = parseEnsureResult(await withLeaseHeartbeat(allocation, leaseId, () => provisioner.ensure({
299
+ result = parseEnsureResult(await withReconciliationLease(allocation, leaseId, "Sidecar ensure", ({ signal }) => provisioner.ensure({
300
+ signal,
153
301
  allocationId: allocation.id,
154
302
  generation: allocation.generation,
155
303
  tenantId: allocation.tenantId,
@@ -157,15 +305,17 @@ export function createSidecarAllocationReconciler({ allocationStore, plugins, ro
157
305
  sidecarId,
158
306
  token,
159
307
  hubWebSocketUrl,
160
- })));
308
+ }), operationTimeoutMs));
161
309
  }
162
310
  catch (error) {
311
+ if (error instanceof ReconciliationLeaseLostError)
312
+ throw error;
163
313
  await replaceAfterFailure(allocation, leaseId, "ensure_failed", error instanceof Error ? error.message : String(error));
164
314
  return;
165
315
  }
166
316
  if (result.kind === "rejected") {
167
317
  if (!result.retryable) {
168
- await allocationStore.failWithoutInfrastructure({
318
+ const failed = await allocationStore.failWithoutInfrastructure({
169
319
  allocationId: allocation.id,
170
320
  expectedStatus: "provisioning",
171
321
  expectedGeneration: allocation.generation,
@@ -174,6 +324,12 @@ export function createSidecarAllocationReconciler({ allocationStore, plugins, ro
174
324
  expectedLeaseId: leaseId,
175
325
  now: now(),
176
326
  });
327
+ if (failed !== null) {
328
+ router.retireAllocation({
329
+ allocationId: failed.id,
330
+ generation: failed.generation,
331
+ });
332
+ }
177
333
  return;
178
334
  }
179
335
  await replaceAfterFailure(allocation, leaseId, result.code, result.message);
@@ -189,11 +345,8 @@ export function createSidecarAllocationReconciler({ allocationStore, plugins, ro
189
345
  now: now(),
190
346
  });
191
347
  if (allocated !== null) {
192
- const target = {
193
- allocationId: allocated.id,
194
- generation: allocated.generation,
195
- };
196
- if (await router.isAllocatedSidecarReady(target)) {
348
+ trackAllocation(allocated);
349
+ if (await isSidecarReady(allocated)) {
197
350
  await waitUntilReady(allocated, leaseId, true);
198
351
  return;
199
352
  }
@@ -201,10 +354,19 @@ export function createSidecarAllocationReconciler({ allocationStore, plugins, ro
201
354
  // transitions. Do not hold the single reconciliation loop for the full
202
355
  // connection timeout: park this lease at its persisted deadline and let
203
356
  // sidecar.allocated.connected wake it immediately when the worker arrives.
204
- await allocationStore.parkReconciliation(allocated.id, leaseId, {
205
- kind: "await-connection",
206
- fallbackNextAttemptAt: retryAt(MAX_RETRY_BACKOFF_ATTEMPT),
207
- });
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
+ }));
208
370
  }
209
371
  }
210
372
  async function bindAndEnsure(allocation, leaseId, provisioner, replacement) {
@@ -232,6 +394,7 @@ export function createSidecarAllocationReconciler({ allocationStore, plugins, ro
232
394
  });
233
395
  if (bound === null)
234
396
  return;
397
+ trackAllocation(bound);
235
398
  router.fenceAllocation(bound.id, bound.generation);
236
399
  await acceptEnsure(bound, leaseId, provisioner, token);
237
400
  }
@@ -240,47 +403,77 @@ export function createSidecarAllocationReconciler({ allocationStore, plugins, ro
240
403
  allocation.status !== "releasing") {
241
404
  throw new Error(`Cannot retry destroy while allocation ${allocation.id} is ${allocation.status}`);
242
405
  }
243
- await allocationStore.scheduleRetry({
406
+ const status = allocation.status;
407
+ await finishReconciliation(allocation.id, () => allocationStore.scheduleRetry({
244
408
  allocationId: allocation.id,
245
- expectedStatus: allocation.status,
409
+ expectedStatus: status,
246
410
  expectedGeneration: allocation.generation,
247
411
  nextAttemptAt: retryAt(allocation.destroyAttempts),
248
412
  expectedLeaseId: leaseId,
249
413
  attempt: "destroy",
250
414
  now: now(),
251
- });
415
+ }));
252
416
  }
253
417
  async function destroyCurrent(allocation, leaseId, provisioner) {
254
418
  if (allocation.sidecarId === undefined)
255
419
  return true;
256
420
  const sidecarId = allocation.sidecarId;
421
+ let result;
257
422
  try {
258
- const result = parseDestroyResult(await withLeaseHeartbeat(allocation, leaseId, () => provisioner.destroy({
423
+ result = parseDestroyResult(await withReconciliationLease(allocation, leaseId, "Sidecar destroy", ({ signal }) => provisioner.destroy({
424
+ signal,
259
425
  allocationId: allocation.id,
260
426
  generation: allocation.generation,
261
427
  sidecarId,
262
428
  ...(allocation.externalRef !== undefined
263
429
  ? { externalRef: allocation.externalRef }
264
430
  : {}),
265
- })));
266
- if (result.kind === "destroyed")
267
- return true;
431
+ }), operationTimeoutMs));
268
432
  }
269
433
  catch (error) {
434
+ if (error instanceof ReconciliationLeaseLostError)
435
+ throw error;
270
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;
271
458
  }
272
459
  await retryDestroy(allocation, leaseId);
273
460
  return false;
274
461
  }
275
462
  async function reconcile(allocation, leaseId) {
276
463
  router.fenceAllocation(allocation.id, allocation.generation);
277
- if (allocation.status === "released" || allocation.status === "failed") {
464
+ if (allocation.status === "released" ||
465
+ allocation.status === "failed" ||
466
+ allocation.status === "destroy_failed") {
278
467
  return;
279
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
+ }
280
473
  const provisioner = provisionerFor(allocation);
281
474
  if (provisioner === null) {
282
475
  if (allocation.status === "pending") {
283
- await allocationStore.failWithoutInfrastructure({
476
+ const failed = await allocationStore.failWithoutInfrastructure({
284
477
  allocationId: allocation.id,
285
478
  expectedStatus: "pending",
286
479
  expectedGeneration: allocation.generation,
@@ -289,16 +482,23 @@ export function createSidecarAllocationReconciler({ allocationStore, plugins, ro
289
482
  expectedLeaseId: leaseId,
290
483
  now: now(),
291
484
  });
485
+ if (failed !== null) {
486
+ router.retireAllocation({
487
+ allocationId: failed.id,
488
+ generation: failed.generation,
489
+ });
490
+ }
292
491
  }
293
492
  else {
294
- await allocationStore.scheduleRetry({
493
+ const status = allocation.status;
494
+ await finishReconciliation(allocation.id, () => allocationStore.scheduleRetry({
295
495
  allocationId: allocation.id,
296
- expectedStatus: allocation.status,
496
+ expectedStatus: status,
297
497
  expectedGeneration: allocation.generation,
298
498
  nextAttemptAt: retryAt(allocation.ensureAttempts + allocation.destroyAttempts),
299
499
  expectedLeaseId: leaseId,
300
500
  now: now(),
301
- });
501
+ }));
302
502
  }
303
503
  return;
304
504
  }
@@ -312,6 +512,10 @@ export function createSidecarAllocationReconciler({ allocationStore, plugins, ro
312
512
  await replaceAfterFailure(allocation, leaseId, "ensure_outcome_unknown", "Hub restarted before sidecar provisioning acceptance was recorded");
313
513
  return;
314
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
+ }
315
519
  await waitUntilReady(allocation, leaseId);
316
520
  return;
317
521
  case "replacing":
@@ -319,16 +523,23 @@ export function createSidecarAllocationReconciler({ allocationStore, plugins, ro
319
523
  return;
320
524
  await bindAndEnsure(allocation, leaseId, provisioner, true);
321
525
  return;
322
- case "releasing":
526
+ case "releasing": {
323
527
  if (!(await destroyCurrent(allocation, leaseId, provisioner)))
324
528
  return;
325
- await allocationStore.markReleased({
529
+ const released = await allocationStore.markReleased({
326
530
  allocationId: allocation.id,
327
531
  generation: allocation.generation,
328
532
  expectedLeaseId: leaseId,
329
533
  now: now(),
330
534
  });
535
+ if (released !== null) {
536
+ router.retireAllocation({
537
+ allocationId: released.id,
538
+ generation: released.generation,
539
+ });
540
+ }
331
541
  return;
542
+ }
332
543
  default: {
333
544
  const exhaustive = allocation.status;
334
545
  throw new Error(`Allocation ${allocation.id} has unhandled status ${String(exhaustive)}`);
@@ -355,16 +566,72 @@ export function createSidecarAllocationReconciler({ allocationStore, plugins, ro
355
566
  }
356
567
  }
357
568
  }
358
- async function handleDisconnect(target) {
359
- await allocationStore.markConnectionLost({
360
- allocationId: target.allocationId,
361
- generation: target.generation,
362
- connectDeadline: new Date(now().getTime() + connectTimeoutMs),
363
- now: now(),
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);
364
627
  });
365
628
  }
366
- async function handleConnected(target) {
367
- await allocationStore.wakeReconciliation(target.allocationId, target.generation);
629
+ function handleConnected(target) {
630
+ noteConnect(target);
631
+ return queueConnectionEvent(target, async () => {
632
+ await allocationStore.wakeReconciliation(target.allocationId, target.generation);
633
+ noteConnect(target);
634
+ });
368
635
  }
369
636
  async function repairUnscheduledConnections() {
370
637
  for (const allocation of await allocationStore.listActive()) {
@@ -378,8 +645,17 @@ export function createSidecarAllocationReconciler({ allocationStore, plugins, ro
378
645
  allocationId: allocation.id,
379
646
  generation: allocation.generation,
380
647
  };
381
- if (await router.isAllocatedSidecarReady(target))
382
- continue;
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
+ }
383
659
  try {
384
660
  await allocationStore.scheduleReconnectIfUnscheduled({
385
661
  ...target,
@@ -393,23 +669,141 @@ export function createSidecarAllocationReconciler({ allocationStore, plugins, ro
393
669
  }
394
670
  }
395
671
  }
672
+ let admittedClaims = 0;
396
673
  async function reconcileNext() {
397
- const leaseId = createLeaseId();
398
- const allocation = await allocationStore.claimNextReconcilable({
399
- leaseId,
400
- leaseDurationMs,
401
- });
402
- if (allocation === null)
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)
403
682
  return false;
683
+ admittedClaims += 1;
684
+ let claim;
404
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();
405
781
  await reconcile(allocation, leaseId);
406
782
  }
407
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
+ }
408
796
  logger.error `Allocation ${allocation.id} reconciliation failed: ${error instanceof Error ? error.message : String(error)}`;
409
- await allocationStore.parkReconciliation(allocation.id, leaseId, {
797
+ await finishReconciliation(allocation.id, () => allocationStore.parkReconciliation(allocation.id, leaseId, {
410
798
  kind: "retry-after-error",
411
799
  notBefore: retryAt(MAX_RETRY_BACKOFF_ATTEMPT),
412
- });
800
+ }));
801
+ }
802
+ finally {
803
+ finished = true;
804
+ clearInterval(heartbeat);
805
+ clearTimeout(expiryTimer);
806
+ activeAllocations.delete(allocation.id);
413
807
  }
414
808
  return true;
415
809
  }