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.
- package/.agents/skills/retrospective-walk/SKILL.md +294 -0
- package/CHANGELOG.md +52 -0
- package/README.md +19 -7
- package/dist/async-job-manager.d.ts +36 -1
- package/dist/async-job-manager.js +276 -20
- package/dist/config.d.ts +11 -0
- package/dist/config.js +37 -1
- package/dist/index.js +20 -3
- package/dist/job-store.d.ts +75 -5
- package/dist/job-store.js +248 -39
- package/dist/postgres-job-store-worker.js +124 -7
- package/dist/sqlite-driver.d.ts +1 -1
- package/dist/sqlite-driver.js +2 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
this.
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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) {
|