llm-cli-gateway 2.14.0-rc.1 → 2.14.1

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.
@@ -1,4 +1,6 @@
1
1
  import { randomUUID } from "crypto";
2
+ import os from "os";
3
+ import { hrtime } from "process";
2
4
  import { envWithExtendedPath, getExtendedPath, killProcessGroup, providerCommandName, spawnCliProcess, unregisterProcessGroup, } from "./executor.js";
3
5
  import { noopLogger, logWarn } from "./logger.js";
4
6
  import { ProcessMonitor } from "./process-monitor.js";
@@ -8,6 +10,16 @@ import { codexFrResponse } from "./codex-json-parser.js";
8
10
  import { extractProviderOutputMetadata } from "./provider-output-metadata.js";
9
11
  import { getRequestContext, resolveOwnerPrincipal, principalCanAccess } from "./request-context.js";
10
12
  import { runApiRequest, ApiHttpError, } from "./api-provider.js";
13
+ import { DEFAULT_INSTANCE_HEARTBEAT_MS, DEFAULT_INSTANCE_LEASE_TTL_MS, DEFAULT_HTTP_JOB_GRACE_MS, DEFAULT_ORPHAN_SWEEP_INTERVAL_MS, DEFAULT_INSTANCE_GC_MS, } from "./config.js";
14
+ const DEFAULT_LEASE_RUNTIME_CONFIG = {
15
+ instanceHeartbeatMs: DEFAULT_INSTANCE_HEARTBEAT_MS,
16
+ instanceLeaseTtlMs: DEFAULT_INSTANCE_LEASE_TTL_MS,
17
+ httpJobGraceMs: DEFAULT_HTTP_JOB_GRACE_MS,
18
+ orphanSweepIntervalMs: DEFAULT_ORPHAN_SWEEP_INTERVAL_MS,
19
+ instanceGcMs: DEFAULT_INSTANCE_GC_MS,
20
+ role: "gateway",
21
+ };
22
+ const MAX_CONSECUTIVE_HEARTBEAT_FAILURES = 3;
11
23
  export function extractApiHttpStatus(error) {
12
24
  for (const candidate of [error, error?.cause]) {
13
25
  if (candidate instanceof ApiHttpError && typeof candidate.status === "number") {
@@ -238,13 +250,26 @@ export class AsyncJobManager {
238
250
  limiter;
239
251
  completedJobMemoryTtlMs;
240
252
  maxJobOutputBytes;
241
- constructor(logger = noopLogger, onJobComplete, store = null, flightRecorder = new NoopFlightRecorder(), limits = DEFAULT_MANAGER_JOB_LIMITS) {
253
+ instanceId = randomUUID();
254
+ hostname = os.hostname();
255
+ instancePid = process.pid;
256
+ lease;
257
+ heartbeatTimer = null;
258
+ sweepTimer = null;
259
+ durableAdmission;
260
+ consecutiveHeartbeatFailures = 0;
261
+ skipSweepThisCycle = false;
262
+ nextHeartbeatExpectedAt = 0;
263
+ disposed = false;
264
+ pendingWrites = new Set();
265
+ constructor(logger = noopLogger, onJobComplete, store = null, flightRecorder = new NoopFlightRecorder(), limits = DEFAULT_MANAGER_JOB_LIMITS, _deprecatedOwnsOrphanRecovery = true, leaseConfig = DEFAULT_LEASE_RUNTIME_CONFIG) {
242
266
  this.logger = logger;
243
267
  this.onJobComplete = onJobComplete;
244
268
  this.processMonitor = new ProcessMonitor(logger);
245
269
  this.store = store;
246
270
  this.flightRecorder = flightRecorder;
247
271
  this.limits = limits;
272
+ this.lease = leaseConfig;
248
273
  this.completedJobMemoryTtlMs = limits.completedJobMemoryTtlMs;
249
274
  this.maxJobOutputBytes = limits.maxJobOutputBytes;
250
275
  this.limiter = new JobLimiter({
@@ -253,23 +278,24 @@ export class AsyncJobManager {
253
278
  maxQueuedJobs: limits.maxQueuedJobs,
254
279
  queueTimeoutMs: limits.queueTimeoutMs,
255
280
  }, logger);
281
+ this.durableAdmission = store !== null;
256
282
  if (this.store) {
257
283
  try {
258
- const { count, orphaned } = this.store.markOrphanedOnStartup();
259
- if (count > 0) {
260
- this.logger.info(`Marked ${count} in-flight job(s) as orphaned after gateway restart`);
261
- }
262
- for (const orphan of orphaned) {
263
- try {
264
- this.flightRecorder.logComplete(orphan.correlationId, this.buildOrphanFlightResult(orphan));
265
- }
266
- catch (err) {
267
- this.logger.error(`Async-path FR logComplete for orphaned job ${orphan.id} failed`, err);
268
- }
269
- }
284
+ this.store.registerInstance({
285
+ instanceId: this.instanceId,
286
+ role: this.lease.role ?? "gateway",
287
+ hostname: this.hostname,
288
+ pid: this.instancePid,
289
+ });
270
290
  }
271
291
  catch (err) {
272
- this.logger.error("markOrphanedOnStartup failed", err);
292
+ this.durableAdmission = false;
293
+ this.logger.error("#139 registerInstance failed; durable async admission is disabled for this instance", err);
294
+ }
295
+ if (this.durableAdmission) {
296
+ this.runOrphanSweep();
297
+ this.startHeartbeat();
298
+ this.startReaper();
273
299
  }
274
300
  }
275
301
  this.evictionTimer = setInterval(() => this.evictCompletedJobs(), EVICTION_INTERVAL_MS);
@@ -281,6 +307,212 @@ export class AsyncJobManager {
281
307
  this.stallTimer.unref();
282
308
  }
283
309
  }
310
+ canAdmitDurableJobs() {
311
+ return this.store !== null && this.durableAdmission;
312
+ }
313
+ assertDurableAdmission(provider) {
314
+ if (this.store && !this.durableAdmission) {
315
+ throw new Error(`Durable async admission is disabled for ${provider}: this gateway instance could not register or lost its heartbeat lease. Retry after the gateway recovers.`);
316
+ }
317
+ }
318
+ trackPendingWrite(p) {
319
+ this.pendingWrites.add(p);
320
+ void p.finally(() => this.pendingWrites.delete(p));
321
+ }
322
+ async dispose(opts = {}) {
323
+ const timeoutMs = opts.timeoutMs ?? 5000;
324
+ if (this.disposed)
325
+ return;
326
+ this.disposed = true;
327
+ this.durableAdmission = false;
328
+ for (const t of [this.heartbeatTimer, this.sweepTimer, this.evictionTimer, this.stallTimer]) {
329
+ if (t)
330
+ clearInterval(t);
331
+ }
332
+ this.heartbeatTimer = null;
333
+ this.sweepTimer = null;
334
+ this.evictionTimer = null;
335
+ this.stallTimer = null;
336
+ if (!this.store)
337
+ return;
338
+ const active = [...this.jobs.values()].filter(job => isAsyncJobInProgress(job.status));
339
+ for (const job of active) {
340
+ try {
341
+ if (job.transport === "http") {
342
+ job.abort?.abort();
343
+ }
344
+ else if (job.process) {
345
+ killProcessGroup(job.process, "SIGTERM");
346
+ }
347
+ }
348
+ catch (err) {
349
+ this.logger.error(`#139 dispose: failed to signal job ${job.id}`, err);
350
+ }
351
+ }
352
+ const deadline = Date.now() + timeoutMs;
353
+ while (Date.now() < deadline) {
354
+ const stillActive = [...this.jobs.values()].some(job => isAsyncJobInProgress(job.status));
355
+ if (!stillActive && this.pendingWrites.size === 0)
356
+ break;
357
+ await Promise.race([
358
+ Promise.allSettled([...this.pendingWrites]),
359
+ new Promise(resolve => setTimeout(resolve, 50)),
360
+ ]);
361
+ }
362
+ const stillActive = [...this.jobs.values()].some(job => isAsyncJobInProgress(job.status));
363
+ if (stillActive) {
364
+ logWarn(this.logger, "#139 dispose timed out with active owned jobs; skipping deregister and letting the lease expire");
365
+ return;
366
+ }
367
+ try {
368
+ this.store.deregisterInstance(this.instanceId);
369
+ }
370
+ catch (err) {
371
+ this.logger.error("#139 dispose: deregisterInstance failed", err);
372
+ }
373
+ }
374
+ getInstanceId() {
375
+ return this.instanceId;
376
+ }
377
+ startHeartbeat() {
378
+ const intervalMs = this.lease.instanceHeartbeatMs;
379
+ this.nextHeartbeatExpectedAt = Number(hrtime.bigint() / 1000000n) + intervalMs;
380
+ this.heartbeatTimer = setInterval(() => this.onHeartbeatTick(intervalMs), intervalMs);
381
+ if (this.heartbeatTimer.unref)
382
+ this.heartbeatTimer.unref();
383
+ }
384
+ onHeartbeatTick(intervalMs) {
385
+ if (this.disposed || !this.store)
386
+ return;
387
+ const nowMonoMs = Number(hrtime.bigint() / 1000000n);
388
+ const driftMs = nowMonoMs - this.nextHeartbeatExpectedAt;
389
+ this.nextHeartbeatExpectedAt = nowMonoMs + intervalMs;
390
+ if (driftMs > intervalMs) {
391
+ this.skipSweepThisCycle = true;
392
+ logWarn(this.logger, `#139 heartbeat scheduling drift ${Math.round(driftMs)}ms exceeded interval ${intervalMs}ms; skipping the next orphan sweep`);
393
+ }
394
+ try {
395
+ this.store.heartbeat(this.instanceId);
396
+ this.consecutiveHeartbeatFailures = 0;
397
+ }
398
+ catch (err) {
399
+ this.consecutiveHeartbeatFailures++;
400
+ this.logger.error(`#139 heartbeat failed (${this.consecutiveHeartbeatFailures}/${MAX_CONSECUTIVE_HEARTBEAT_FAILURES})`, err);
401
+ if (this.consecutiveHeartbeatFailures >= MAX_CONSECUTIVE_HEARTBEAT_FAILURES) {
402
+ if (this.durableAdmission) {
403
+ this.durableAdmission = false;
404
+ this.logger.error("#139 sustained heartbeat failure; disabling durable admission and orphan sweeping on this instance");
405
+ }
406
+ }
407
+ }
408
+ }
409
+ runOrphanSweepNow() {
410
+ this.runOrphanSweep();
411
+ }
412
+ startReaper() {
413
+ this.sweepTimer = setInterval(() => {
414
+ this.runOrphanSweep();
415
+ try {
416
+ this.store?.gcInstances(this.lease.instanceGcMs);
417
+ }
418
+ catch (err) {
419
+ this.logger.error("#139 gateway_instances GC failed", err);
420
+ }
421
+ }, this.lease.orphanSweepIntervalMs);
422
+ if (this.sweepTimer.unref)
423
+ this.sweepTimer.unref();
424
+ }
425
+ runOrphanSweep() {
426
+ if (!this.store || this.disposed || !this.durableAdmission)
427
+ return;
428
+ if (this.skipSweepThisCycle) {
429
+ this.skipSweepThisCycle = false;
430
+ return;
431
+ }
432
+ const leaseTtl = this.lease.instanceLeaseTtlMs;
433
+ const httpGrace = this.lease.httpJobGraceMs;
434
+ let liveConfirmedIds = [];
435
+ try {
436
+ const candidates = this.store.selectStaleProcessCandidates(leaseTtl, httpGrace);
437
+ liveConfirmedIds = this.confirmLiveProcessCandidates(candidates);
438
+ }
439
+ catch (err) {
440
+ this.logger.error("#139 selecting stale process candidates failed", err);
441
+ }
442
+ let orphaned;
443
+ try {
444
+ orphaned = this.store.recoverStaleJobs(leaseTtl, httpGrace, liveConfirmedIds);
445
+ }
446
+ catch (err) {
447
+ this.logger.error("#139 recoverStaleJobs failed", err);
448
+ return;
449
+ }
450
+ if (orphaned.length > 0) {
451
+ this.logger.info(`#139 orphaned ${orphaned.length} stale job(s) whose owning instance is gone`);
452
+ }
453
+ for (const orphan of orphaned) {
454
+ try {
455
+ this.flightRecorder.logComplete(orphan.correlationId, this.buildOrphanFlightResult(orphan));
456
+ }
457
+ catch (err) {
458
+ this.logger.error(`#139 FR logComplete for orphaned job ${orphan.id} failed`, err);
459
+ }
460
+ }
461
+ }
462
+ confirmLiveProcessCandidates(candidates) {
463
+ const live = [];
464
+ for (const c of candidates) {
465
+ if (c.transport !== "process" || c.pid == null)
466
+ continue;
467
+ if (c.hostname !== null && c.hostname !== this.hostname)
468
+ continue;
469
+ if (c.hostname === null)
470
+ continue;
471
+ try {
472
+ process.kill(c.pid, 0);
473
+ live.push(c.id);
474
+ }
475
+ catch (err) {
476
+ const code = err.code;
477
+ if (code === "EPERM")
478
+ live.push(c.id);
479
+ }
480
+ }
481
+ return live;
482
+ }
483
+ recordStartOrFailClosed(job, acq, input) {
484
+ if (!this.store)
485
+ return;
486
+ try {
487
+ this.store.recordStart(input);
488
+ }
489
+ catch (err) {
490
+ if (acq.state === "granted")
491
+ acq.permit.release();
492
+ else
493
+ acq.cancel();
494
+ this.jobs.delete(job.id);
495
+ this.logger.error(`#139 durable recordStart failed for job ${job.id}; failing the request (fail-closed)`, err);
496
+ throw new Error(`Durable job admission failed for ${job.cli}: the job store rejected recordStart`);
497
+ }
498
+ }
499
+ markRunningDurable(job, pid, failClosed = false) {
500
+ if (!this.store)
501
+ return;
502
+ let transitioned = false;
503
+ try {
504
+ transitioned = this.store.markRunning(job.id, { pid });
505
+ }
506
+ catch (err) {
507
+ if (failClosed)
508
+ throw err;
509
+ this.logger.error(`#139 markRunning (best-effort) failed for job ${job.id}`, err);
510
+ return;
511
+ }
512
+ if (!transitioned && failClosed) {
513
+ throw new Error(`#139 markRunning matched no queued row for job ${job.id} (already recovered or terminal); refusing to run a child against a stale durable row`);
514
+ }
515
+ }
284
516
  buildOrphanFlightResult(orphan) {
285
517
  const durationMs = Math.max(0, Date.now() - new Date(orphan.startedAt).getTime());
286
518
  const hasCapturedStdout = orphan.stdout.length > 0;
@@ -500,6 +732,7 @@ export class AsyncJobManager {
500
732
  if (reused)
501
733
  return reused;
502
734
  }
735
+ this.assertDurableAdmission(provider.name);
503
736
  const id = randomUUID();
504
737
  const startedAt = new Date().toISOString();
505
738
  const abort = new AbortController();
@@ -555,16 +788,18 @@ export class AsyncJobManager {
555
788
  return;
556
789
  }
557
790
  job.status = "running";
558
- runApiRequest(provider, apiRequest, this.logger, { signal: abort.signal })
791
+ this.markRunningDurable(job, null);
792
+ const settle = runApiRequest(provider, apiRequest, this.logger, { signal: abort.signal })
559
793
  .then(result => this.finalizeHttpJob(job, result, null))
560
794
  .catch(error => this.finalizeHttpJob(job, null, error));
795
+ this.trackPendingWrite(settle);
561
796
  };
562
797
  const acq = this.limiter.acquire(provider.name, permit => launch(permit), () => this.failQueuedJob(job, "queue wait timed out before a run slot was free"));
563
798
  if (acq.state === "rejected") {
564
799
  this.jobs.delete(id);
565
800
  throw new JobSaturationError(provider.name, `running limit (${this.limits.maxRunningJobs} global / ${this.limits.maxRunningJobsPerProvider} per provider) reached and the queue is full (max ${this.limits.maxQueuedJobs}); retry shortly`);
566
801
  }
567
- this.safeStoreCall("recordStart", () => this.store.recordStart({
802
+ this.recordStartOrFailClosed(job, acq, {
568
803
  id,
569
804
  correlationId,
570
805
  requestKey,
@@ -573,9 +808,10 @@ export class AsyncJobManager {
573
808
  startedAt,
574
809
  pid: null,
575
810
  ownerPrincipal,
811
+ ownerInstance: this.instanceId,
576
812
  transport: "http",
577
813
  payloadJson,
578
- }));
814
+ });
579
815
  if (writeFlightStart && flightRecorderEntry) {
580
816
  try {
581
817
  this.flightRecorder.logStart({
@@ -829,7 +1065,7 @@ export class AsyncJobManager {
829
1065
  abort: null,
830
1066
  httpStatus: row.httpStatus,
831
1067
  payloadJson: row.payloadJson,
832
- exited: row.status !== "running",
1068
+ exited: row.status !== "running" && row.status !== "queued",
833
1069
  metricsRecorded: true,
834
1070
  outputFormat: row.outputFormat ?? undefined,
835
1071
  ownerPrincipal: row.ownerPrincipal,
@@ -867,6 +1103,7 @@ export class AsyncJobManager {
867
1103
  if (reused)
868
1104
  return reused;
869
1105
  }
1106
+ this.assertDurableAdmission(cli);
870
1107
  const id = randomUUID();
871
1108
  const startedAt = new Date().toISOString();
872
1109
  const ownerPrincipal = resolveOwnerPrincipal(getRequestContext());
@@ -934,7 +1171,7 @@ export class AsyncJobManager {
934
1171
  this.jobs.delete(id);
935
1172
  throw new JobSaturationError(cli, `running limit (${this.limits.maxRunningJobs} global / ${this.limits.maxRunningJobsPerProvider} per provider) reached and the queue is full (max ${this.limits.maxQueuedJobs}); retry shortly`);
936
1173
  }
937
- this.safeStoreCall("recordStart", () => this.store.recordStart({
1174
+ this.recordStartOrFailClosed(job, acq, {
938
1175
  id,
939
1176
  correlationId,
940
1177
  requestKey,
@@ -944,7 +1181,8 @@ export class AsyncJobManager {
944
1181
  startedAt,
945
1182
  pid: null,
946
1183
  ownerPrincipal,
947
- }));
1184
+ ownerInstance: this.instanceId,
1185
+ });
948
1186
  if (writeFlightStart && flightRecorderEntry) {
949
1187
  try {
950
1188
  this.flightRecorder.logStart({
@@ -986,6 +1224,24 @@ export class AsyncJobManager {
986
1224
  env: { ...baseEnv, ...(extraEnv ?? {}) },
987
1225
  });
988
1226
  job.process = child;
1227
+ try {
1228
+ this.markRunningDurable(job, child.pid ?? null, true);
1229
+ }
1230
+ catch (err) {
1231
+ try {
1232
+ killProcessGroup(child, "SIGKILL");
1233
+ }
1234
+ catch {
1235
+ }
1236
+ if (child.pid) {
1237
+ try {
1238
+ unregisterProcessGroup(child.pid);
1239
+ }
1240
+ catch {
1241
+ }
1242
+ }
1243
+ throw err;
1244
+ }
989
1245
  if (stdin !== undefined && child.stdin) {
990
1246
  try {
991
1247
  child.stdin.write(stdin);
package/dist/config.d.ts CHANGED
@@ -20,6 +20,11 @@ export declare const PERSISTENCE_BACKENDS: readonly ["sqlite", "postgres", "memo
20
20
  export type PersistenceBackend = (typeof PERSISTENCE_BACKENDS)[number];
21
21
  export declare const DEFAULT_JOB_RETENTION_DAYS = 30;
22
22
  export declare const DEFAULT_DEDUP_WINDOW_MS: number;
23
+ export declare const DEFAULT_INSTANCE_HEARTBEAT_MS = 15000;
24
+ export declare const DEFAULT_INSTANCE_LEASE_TTL_MS = 90000;
25
+ export declare const DEFAULT_HTTP_JOB_GRACE_MS = 300000;
26
+ export declare const DEFAULT_ORPHAN_SWEEP_INTERVAL_MS = 30000;
27
+ export declare const DEFAULT_INSTANCE_GC_MS = 3600000;
23
28
  export interface PersistenceConfig {
24
29
  backend: PersistenceBackend;
25
30
  path: string | null;
@@ -27,6 +32,12 @@ export interface PersistenceConfig {
27
32
  retentionDays: number;
28
33
  dedupWindowMs: number;
29
34
  acknowledgeEphemeral: boolean;
35
+ ownsOrphanRecovery: boolean;
36
+ instanceHeartbeatMs: number;
37
+ instanceLeaseTtlMs: number;
38
+ httpJobGraceMs: number;
39
+ orphanSweepIntervalMs: number;
40
+ instanceGcMs: number;
30
41
  asyncJobsEnabled: boolean;
31
42
  sources: PersistenceConfigSources;
32
43
  }
package/dist/config.js CHANGED
@@ -45,6 +45,11 @@ export function loadConfig() {
45
45
  export const PERSISTENCE_BACKENDS = ["sqlite", "postgres", "memory", "none"];
46
46
  export const DEFAULT_JOB_RETENTION_DAYS = 30;
47
47
  export const DEFAULT_DEDUP_WINDOW_MS = 60 * 60 * 1000;
48
+ export const DEFAULT_INSTANCE_HEARTBEAT_MS = 15000;
49
+ export const DEFAULT_INSTANCE_LEASE_TTL_MS = 90000;
50
+ export const DEFAULT_HTTP_JOB_GRACE_MS = 300000;
51
+ export const DEFAULT_ORPHAN_SWEEP_INTERVAL_MS = 30000;
52
+ export const DEFAULT_INSTANCE_GC_MS = 3600000;
48
53
  const PersistenceSchema = z
49
54
  .object({
50
55
  backend: z.enum(PERSISTENCE_BACKENDS).default("sqlite"),
@@ -53,8 +58,30 @@ const PersistenceSchema = z
53
58
  retentionDays: z.number().positive().default(DEFAULT_JOB_RETENTION_DAYS),
54
59
  dedupWindowMs: z.number().int().nonnegative().default(DEFAULT_DEDUP_WINDOW_MS),
55
60
  acknowledgeEphemeral: z.boolean().default(false),
61
+ ownsOrphanRecovery: z.boolean().default(false),
62
+ instanceHeartbeatMs: z.number().int().positive().default(DEFAULT_INSTANCE_HEARTBEAT_MS),
63
+ instanceLeaseTtlMs: z.number().int().positive().default(DEFAULT_INSTANCE_LEASE_TTL_MS),
64
+ httpJobGraceMs: z.number().int().positive().default(DEFAULT_HTTP_JOB_GRACE_MS),
65
+ orphanSweepIntervalMs: z.number().int().positive().default(DEFAULT_ORPHAN_SWEEP_INTERVAL_MS),
66
+ instanceGcMs: z.number().int().positive().default(DEFAULT_INSTANCE_GC_MS),
56
67
  })
57
- .strict();
68
+ .strict()
69
+ .superRefine((cfg, ctx) => {
70
+ if (cfg.instanceLeaseTtlMs < 2 * cfg.instanceHeartbeatMs) {
71
+ ctx.addIssue({
72
+ code: z.ZodIssueCode.custom,
73
+ path: ["instanceLeaseTtlMs"],
74
+ message: `instanceLeaseTtlMs (${cfg.instanceLeaseTtlMs}) must be >= 2 * instanceHeartbeatMs (${2 * cfg.instanceHeartbeatMs})`,
75
+ });
76
+ }
77
+ if (cfg.httpJobGraceMs < cfg.instanceLeaseTtlMs) {
78
+ ctx.addIssue({
79
+ code: z.ZodIssueCode.custom,
80
+ path: ["httpJobGraceMs"],
81
+ message: `httpJobGraceMs (${cfg.httpJobGraceMs}) must be >= instanceLeaseTtlMs (${cfg.instanceLeaseTtlMs})`,
82
+ });
83
+ }
84
+ });
58
85
  const DEFAULT_SQLITE_PATH = path.join(os.homedir(), ".llm-cli-gateway", "logs.db");
59
86
  function defaultPersistenceConfigPath() {
60
87
  return (process.env.LLM_GATEWAY_CONFIG ?? path.join(os.homedir(), ".llm-cli-gateway", "config.toml"));
@@ -153,6 +180,9 @@ export function loadPersistenceConfig(logger = noopLogger) {
153
180
  envOverrides: [],
154
181
  };
155
182
  const merged = applyEnvOverrides(raw ?? {}, logger, sources);
183
+ if (merged.ownsOrphanRecovery !== undefined) {
184
+ logWarn(logger, "[persistence].ownsOrphanRecovery is deprecated and no longer used; the durable per-job lease recovery (#139) runs safely from every instance. Remove it from config.toml.");
185
+ }
156
186
  let parsed;
157
187
  try {
158
188
  parsed = PersistenceSchema.parse(merged);
@@ -182,6 +212,12 @@ export function loadPersistenceConfig(logger = noopLogger) {
182
212
  retentionDays: parsed.retentionDays,
183
213
  dedupWindowMs: parsed.dedupWindowMs,
184
214
  acknowledgeEphemeral: parsed.acknowledgeEphemeral,
215
+ ownsOrphanRecovery: parsed.ownsOrphanRecovery,
216
+ instanceHeartbeatMs: parsed.instanceHeartbeatMs,
217
+ instanceLeaseTtlMs: parsed.instanceLeaseTtlMs,
218
+ httpJobGraceMs: parsed.httpJobGraceMs,
219
+ orphanSweepIntervalMs: parsed.orphanSweepIntervalMs,
220
+ instanceGcMs: parsed.instanceGcMs,
185
221
  asyncJobsEnabled,
186
222
  sources,
187
223
  };
package/dist/index.js CHANGED
@@ -291,9 +291,17 @@ function getJobStore(runtimeLogger = logger) {
291
291
  return jobStore;
292
292
  }
293
293
  function newAsyncJobManager(metrics, runtimeLogger, store = getJobStore(runtimeLogger), fr = getFlightRecorder(runtimeLogger)) {
294
+ const pc = getPersistenceConfig(runtimeLogger);
294
295
  return new AsyncJobManager(runtimeLogger, (cli, durationMs, success) => {
295
296
  metrics.recordRequest(cli, durationMs, success);
296
- }, store, fr, getLimitsConfig(runtimeLogger).jobs);
297
+ }, store, fr, getLimitsConfig(runtimeLogger).jobs, true, {
298
+ instanceHeartbeatMs: pc.instanceHeartbeatMs,
299
+ instanceLeaseTtlMs: pc.instanceLeaseTtlMs,
300
+ httpJobGraceMs: pc.httpJobGraceMs,
301
+ orphanSweepIntervalMs: pc.orphanSweepIntervalMs,
302
+ instanceGcMs: pc.instanceGcMs,
303
+ role: process.argv.includes("--transport=http") ? "http" : "stdio",
304
+ });
297
305
  }
298
306
  function getAsyncJobManager(runtimeLogger = logger) {
299
307
  asyncJobManager ??= newAsyncJobManager(performanceMetrics, runtimeLogger);
@@ -573,7 +581,7 @@ async function awaitJobOrDefer(cli, args, corrId, idleTimeoutMs, outputFormat, f
573
581
  }
574
582
  const deferralAvailable = runtime.persistence.backend !== "none" &&
575
583
  runtime.persistence.asyncJobsEnabled &&
576
- runtime.asyncJobManager.hasStore();
584
+ runtime.asyncJobManager.canAdmitDurableJobs();
577
585
  if (SYNC_DEADLINE_MS === 0 || !deferralAvailable) {
578
586
  const command = providerCommandName(cli);
579
587
  let slot;
@@ -662,7 +670,7 @@ async function awaitApiJobOrDefer(provider, apiRequest, corrId, runtime = resolv
662
670
  };
663
671
  const deferralAvailable = runtime.persistence.backend !== "none" &&
664
672
  runtime.persistence.asyncJobsEnabled &&
665
- runtime.asyncJobManager.hasStore();
673
+ runtime.asyncJobManager.canAdmitDurableJobs();
666
674
  if (SYNC_DEADLINE_MS === 0 || !deferralAvailable || forceInline) {
667
675
  let slot;
668
676
  try {
@@ -8282,6 +8290,15 @@ function registerHealthResource(server) {
8282
8290
  async function shutdown(signal) {
8283
8291
  logger.info(`Received ${signal}, shutting down gracefully...`);
8284
8292
  try {
8293
+ if (asyncJobManager) {
8294
+ try {
8295
+ await asyncJobManager.dispose({ timeoutMs: 5000 });
8296
+ logger.info("Async job manager disposed");
8297
+ }
8298
+ catch (err) {
8299
+ logger.error("Async job manager dispose failed", err);
8300
+ }
8301
+ }
8285
8302
  await killAllProcessGroups();
8286
8303
  logger.info("All process groups terminated");
8287
8304
  if (activeHttpGateway) {