llm-cli-gateway 2.13.2 → 2.14.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.
- package/CHANGELOG.md +139 -0
- package/README.md +68 -29
- package/dist/acp/client.d.ts +29 -1
- package/dist/acp/client.js +78 -4
- package/dist/acp/errors.d.ts +9 -1
- package/dist/acp/errors.js +19 -0
- package/dist/acp/event-normalizer.d.ts +12 -0
- package/dist/acp/event-normalizer.js +16 -0
- package/dist/acp/flight-redaction.d.ts +3 -0
- package/dist/acp/flight-redaction.js +3 -0
- package/dist/acp/permission-bridge.js +11 -5
- package/dist/acp/process-manager.d.ts +2 -1
- package/dist/acp/process-manager.js +43 -4
- package/dist/acp/provider-registry.js +19 -11
- package/dist/acp/runtime.d.ts +8 -0
- package/dist/acp/runtime.js +47 -4
- package/dist/acp/types.d.ts +3083 -55
- package/dist/acp/types.js +242 -5
- package/dist/async-job-manager.d.ts +38 -1
- package/dist/async-job-manager.js +287 -20
- package/dist/codex-json-parser.d.ts +1 -0
- package/dist/codex-json-parser.js +6 -0
- package/dist/config.d.ts +19 -0
- package/dist/config.js +84 -1
- package/dist/flight-recorder.d.ts +2 -0
- package/dist/flight-recorder.js +20 -0
- package/dist/gemini-json-parser.d.ts +2 -0
- package/dist/gemini-json-parser.js +45 -8
- package/dist/grok-json-parser.d.ts +14 -0
- package/dist/grok-json-parser.js +156 -0
- package/dist/index.d.ts +47 -2
- package/dist/index.js +504 -122
- package/dist/job-store.d.ts +119 -11
- package/dist/job-store.js +372 -42
- package/dist/model-registry.d.ts +1 -0
- package/dist/model-registry.js +46 -0
- package/dist/oauth.js +2 -2
- package/dist/postgres-job-store-worker.d.ts +1 -0
- package/dist/postgres-job-store-worker.js +444 -0
- package/dist/pricing.d.ts +3 -2
- package/dist/provider-acp-capabilities.d.ts +52 -0
- package/dist/provider-acp-capabilities.js +101 -0
- package/dist/provider-admin-tools.d.ts +100 -0
- package/dist/provider-admin-tools.js +572 -0
- package/dist/provider-capability-cache.d.ts +46 -0
- package/dist/provider-capability-cache.js +248 -0
- package/dist/provider-capability-discovery.d.ts +85 -0
- package/dist/provider-capability-discovery.js +461 -0
- package/dist/provider-capability-resolver.d.ts +29 -0
- package/dist/provider-capability-resolver.js +92 -0
- package/dist/provider-definition-assertions.d.ts +9 -0
- package/dist/provider-definition-assertions.js +147 -0
- package/dist/provider-definitions.d.ts +127 -0
- package/dist/provider-definitions.js +758 -0
- package/dist/provider-help-parser.d.ts +34 -0
- package/dist/provider-help-parser.js +203 -0
- package/dist/provider-model-discovery.d.ts +30 -0
- package/dist/provider-model-discovery.js +229 -0
- package/dist/provider-output-metadata.d.ts +7 -0
- package/dist/provider-output-metadata.js +68 -0
- package/dist/provider-schema-builder.d.ts +22 -0
- package/dist/provider-schema-builder.js +55 -0
- package/dist/provider-surface-generator.d.ts +98 -0
- package/dist/provider-surface-generator.js +140 -0
- package/dist/provider-tool-capabilities.d.ts +3 -2
- package/dist/provider-tool-capabilities.js +77 -62
- package/dist/request-helpers.d.ts +37 -4
- package/dist/request-helpers.js +134 -0
- package/dist/resources.d.ts +6 -1
- package/dist/resources.js +64 -192
- package/dist/session-manager.js +18 -11
- package/dist/sqlite-driver.d.ts +1 -1
- package/dist/sqlite-driver.js +2 -1
- package/dist/stream-json-parser.d.ts +1 -0
- package/dist/stream-json-parser.js +3 -0
- package/dist/upstream-contracts.d.ts +17 -1
- package/dist/upstream-contracts.js +209 -36
- package/npm-shrinkwrap.json +2 -2
- package/package.json +8 -3
|
@@ -1,12 +1,25 @@
|
|
|
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";
|
|
5
7
|
import { computeRequestKey, isValidationRunStore } from "./job-store.js";
|
|
6
8
|
import { NoopFlightRecorder, } from "./flight-recorder.js";
|
|
7
9
|
import { codexFrResponse } from "./codex-json-parser.js";
|
|
10
|
+
import { extractProviderOutputMetadata } from "./provider-output-metadata.js";
|
|
8
11
|
import { getRequestContext, resolveOwnerPrincipal, principalCanAccess } from "./request-context.js";
|
|
9
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;
|
|
10
23
|
export function extractApiHttpStatus(error) {
|
|
11
24
|
for (const candidate of [error, error?.cause]) {
|
|
12
25
|
if (candidate instanceof ApiHttpError && typeof candidate.status === "number") {
|
|
@@ -237,13 +250,26 @@ export class AsyncJobManager {
|
|
|
237
250
|
limiter;
|
|
238
251
|
completedJobMemoryTtlMs;
|
|
239
252
|
maxJobOutputBytes;
|
|
240
|
-
|
|
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) {
|
|
241
266
|
this.logger = logger;
|
|
242
267
|
this.onJobComplete = onJobComplete;
|
|
243
268
|
this.processMonitor = new ProcessMonitor(logger);
|
|
244
269
|
this.store = store;
|
|
245
270
|
this.flightRecorder = flightRecorder;
|
|
246
271
|
this.limits = limits;
|
|
272
|
+
this.lease = leaseConfig;
|
|
247
273
|
this.completedJobMemoryTtlMs = limits.completedJobMemoryTtlMs;
|
|
248
274
|
this.maxJobOutputBytes = limits.maxJobOutputBytes;
|
|
249
275
|
this.limiter = new JobLimiter({
|
|
@@ -252,23 +278,24 @@ export class AsyncJobManager {
|
|
|
252
278
|
maxQueuedJobs: limits.maxQueuedJobs,
|
|
253
279
|
queueTimeoutMs: limits.queueTimeoutMs,
|
|
254
280
|
}, logger);
|
|
281
|
+
this.durableAdmission = store !== null;
|
|
255
282
|
if (this.store) {
|
|
256
283
|
try {
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
this.
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
this.flightRecorder.logComplete(orphan.correlationId, this.buildOrphanFlightResult(orphan));
|
|
264
|
-
}
|
|
265
|
-
catch (err) {
|
|
266
|
-
this.logger.error(`Async-path FR logComplete for orphaned job ${orphan.id} failed`, err);
|
|
267
|
-
}
|
|
268
|
-
}
|
|
284
|
+
this.store.registerInstance({
|
|
285
|
+
instanceId: this.instanceId,
|
|
286
|
+
role: this.lease.role ?? "gateway",
|
|
287
|
+
hostname: this.hostname,
|
|
288
|
+
pid: this.instancePid,
|
|
289
|
+
});
|
|
269
290
|
}
|
|
270
291
|
catch (err) {
|
|
271
|
-
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();
|
|
272
299
|
}
|
|
273
300
|
}
|
|
274
301
|
this.evictionTimer = setInterval(() => this.evictCompletedJobs(), EVICTION_INTERVAL_MS);
|
|
@@ -280,6 +307,212 @@ export class AsyncJobManager {
|
|
|
280
307
|
this.stallTimer.unref();
|
|
281
308
|
}
|
|
282
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
|
+
}
|
|
283
516
|
buildOrphanFlightResult(orphan) {
|
|
284
517
|
const durationMs = Math.max(0, Date.now() - new Date(orphan.startedAt).getTime());
|
|
285
518
|
const hasCapturedStdout = orphan.stdout.length > 0;
|
|
@@ -499,6 +732,7 @@ export class AsyncJobManager {
|
|
|
499
732
|
if (reused)
|
|
500
733
|
return reused;
|
|
501
734
|
}
|
|
735
|
+
this.assertDurableAdmission(provider.name);
|
|
502
736
|
const id = randomUUID();
|
|
503
737
|
const startedAt = new Date().toISOString();
|
|
504
738
|
const abort = new AbortController();
|
|
@@ -554,16 +788,18 @@ export class AsyncJobManager {
|
|
|
554
788
|
return;
|
|
555
789
|
}
|
|
556
790
|
job.status = "running";
|
|
557
|
-
|
|
791
|
+
this.markRunningDurable(job, null);
|
|
792
|
+
const settle = runApiRequest(provider, apiRequest, this.logger, { signal: abort.signal })
|
|
558
793
|
.then(result => this.finalizeHttpJob(job, result, null))
|
|
559
794
|
.catch(error => this.finalizeHttpJob(job, null, error));
|
|
795
|
+
this.trackPendingWrite(settle);
|
|
560
796
|
};
|
|
561
797
|
const acq = this.limiter.acquire(provider.name, permit => launch(permit), () => this.failQueuedJob(job, "queue wait timed out before a run slot was free"));
|
|
562
798
|
if (acq.state === "rejected") {
|
|
563
799
|
this.jobs.delete(id);
|
|
564
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`);
|
|
565
801
|
}
|
|
566
|
-
this.
|
|
802
|
+
this.recordStartOrFailClosed(job, acq, {
|
|
567
803
|
id,
|
|
568
804
|
correlationId,
|
|
569
805
|
requestKey,
|
|
@@ -572,9 +808,10 @@ export class AsyncJobManager {
|
|
|
572
808
|
startedAt,
|
|
573
809
|
pid: null,
|
|
574
810
|
ownerPrincipal,
|
|
811
|
+
ownerInstance: this.instanceId,
|
|
575
812
|
transport: "http",
|
|
576
813
|
payloadJson,
|
|
577
|
-
})
|
|
814
|
+
});
|
|
578
815
|
if (writeFlightStart && flightRecorderEntry) {
|
|
579
816
|
try {
|
|
580
817
|
this.flightRecorder.logStart({
|
|
@@ -681,6 +918,9 @@ export class AsyncJobManager {
|
|
|
681
918
|
const errorMessage = isFailure
|
|
682
919
|
? (overrideErrorMessage ?? job.error ?? job.stderr ?? `Exit code ${exitCode}`)
|
|
683
920
|
: undefined;
|
|
921
|
+
const providerMeta = finalStatus === "completed" && job.transport === "process"
|
|
922
|
+
? extractProviderOutputMetadata(job.cli, job.stdout, job.outputFormat)
|
|
923
|
+
: undefined;
|
|
684
924
|
try {
|
|
685
925
|
this.flightRecorder.logComplete(job.correlationId, {
|
|
686
926
|
response,
|
|
@@ -697,6 +937,8 @@ export class AsyncJobManager {
|
|
|
697
937
|
cacheReadTokens: usage.cacheReadTokens,
|
|
698
938
|
cacheCreationTokens: usage.cacheCreationTokens,
|
|
699
939
|
costUsd: usage.costUsd,
|
|
940
|
+
providerSessionId: providerMeta?.sessionId,
|
|
941
|
+
stopReason: providerMeta?.stopReason,
|
|
700
942
|
});
|
|
701
943
|
job.flightRecorderComplete = true;
|
|
702
944
|
job.flightRecorderEntry = undefined;
|
|
@@ -823,7 +1065,7 @@ export class AsyncJobManager {
|
|
|
823
1065
|
abort: null,
|
|
824
1066
|
httpStatus: row.httpStatus,
|
|
825
1067
|
payloadJson: row.payloadJson,
|
|
826
|
-
exited: row.status !== "running",
|
|
1068
|
+
exited: row.status !== "running" && row.status !== "queued",
|
|
827
1069
|
metricsRecorded: true,
|
|
828
1070
|
outputFormat: row.outputFormat ?? undefined,
|
|
829
1071
|
ownerPrincipal: row.ownerPrincipal,
|
|
@@ -861,6 +1103,7 @@ export class AsyncJobManager {
|
|
|
861
1103
|
if (reused)
|
|
862
1104
|
return reused;
|
|
863
1105
|
}
|
|
1106
|
+
this.assertDurableAdmission(cli);
|
|
864
1107
|
const id = randomUUID();
|
|
865
1108
|
const startedAt = new Date().toISOString();
|
|
866
1109
|
const ownerPrincipal = resolveOwnerPrincipal(getRequestContext());
|
|
@@ -928,7 +1171,7 @@ export class AsyncJobManager {
|
|
|
928
1171
|
this.jobs.delete(id);
|
|
929
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`);
|
|
930
1173
|
}
|
|
931
|
-
this.
|
|
1174
|
+
this.recordStartOrFailClosed(job, acq, {
|
|
932
1175
|
id,
|
|
933
1176
|
correlationId,
|
|
934
1177
|
requestKey,
|
|
@@ -938,7 +1181,8 @@ export class AsyncJobManager {
|
|
|
938
1181
|
startedAt,
|
|
939
1182
|
pid: null,
|
|
940
1183
|
ownerPrincipal,
|
|
941
|
-
|
|
1184
|
+
ownerInstance: this.instanceId,
|
|
1185
|
+
});
|
|
942
1186
|
if (writeFlightStart && flightRecorderEntry) {
|
|
943
1187
|
try {
|
|
944
1188
|
this.flightRecorder.logStart({
|
|
@@ -980,6 +1224,24 @@ export class AsyncJobManager {
|
|
|
980
1224
|
env: { ...baseEnv, ...(extraEnv ?? {}) },
|
|
981
1225
|
});
|
|
982
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
|
+
}
|
|
983
1245
|
if (stdin !== undefined && child.stdin) {
|
|
984
1246
|
try {
|
|
985
1247
|
child.stdin.write(stdin);
|
|
@@ -1136,12 +1398,17 @@ export class AsyncJobManager {
|
|
|
1136
1398
|
}
|
|
1137
1399
|
const stdout = truncateText(job.stdout, maxChars);
|
|
1138
1400
|
const stderr = truncateText(job.stderr, maxChars);
|
|
1401
|
+
const providerMeta = job.transport === "process" && job.status === "completed"
|
|
1402
|
+
? extractProviderOutputMetadata(job.cli, job.stdout, job.outputFormat)
|
|
1403
|
+
: undefined;
|
|
1139
1404
|
return {
|
|
1140
1405
|
...this.snapshot(job),
|
|
1141
1406
|
stdout: stdout.text,
|
|
1142
1407
|
stderr: stderr.text,
|
|
1143
1408
|
stdoutTruncated: stdout.truncated,
|
|
1144
1409
|
stderrTruncated: stderr.truncated,
|
|
1410
|
+
...(providerMeta?.sessionId ? { providerSessionId: providerMeta.sessionId } : {}),
|
|
1411
|
+
...(providerMeta?.stopReason ? { stopReason: providerMeta.stopReason } : {}),
|
|
1145
1412
|
...(job.transport === "http"
|
|
1146
1413
|
? {
|
|
1147
1414
|
apiUsage: job.apiUsage,
|
|
@@ -47,6 +47,12 @@ export function parseCodexJsonStream(stdout) {
|
|
|
47
47
|
}
|
|
48
48
|
result.usage = usage;
|
|
49
49
|
}
|
|
50
|
+
if (typeof parsed.stop_reason === "string") {
|
|
51
|
+
result.stopReason = parsed.stop_reason;
|
|
52
|
+
}
|
|
53
|
+
else if (typeof parsed.reason === "string") {
|
|
54
|
+
result.stopReason = parsed.reason;
|
|
55
|
+
}
|
|
50
56
|
break;
|
|
51
57
|
}
|
|
52
58
|
case "turn.failed": {
|
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
|
}
|
|
@@ -155,6 +166,7 @@ export interface AcpConfig {
|
|
|
155
166
|
promptTimeoutMs: number;
|
|
156
167
|
allowWriteHostServices: boolean;
|
|
157
168
|
allowTerminalHostServices: boolean;
|
|
169
|
+
allowMutatingSessionOps: boolean;
|
|
158
170
|
fallbackToCliWhenUnhealthy: boolean;
|
|
159
171
|
providers: Record<string, AcpProviderConfig>;
|
|
160
172
|
sources: {
|
|
@@ -162,6 +174,13 @@ export interface AcpConfig {
|
|
|
162
174
|
};
|
|
163
175
|
}
|
|
164
176
|
export declare function loadAcpConfig(logger?: Logger): AcpConfig;
|
|
177
|
+
export interface AdminConfig {
|
|
178
|
+
allowMutatingCliAdminOps: boolean;
|
|
179
|
+
sources: {
|
|
180
|
+
configFile: string | null;
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
export declare function loadAdminConfig(logger?: Logger): AdminConfig;
|
|
165
184
|
export type RemoteOAuthConfigStatus = "absent" | "disabled" | "enabled" | "malformed";
|
|
166
185
|
export interface RemoteOAuthConfigDiagnostics {
|
|
167
186
|
config: RemoteOAuthConfig;
|
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
|
};
|
|
@@ -537,6 +573,7 @@ const AcpConfigSchema = z
|
|
|
537
573
|
prompt_timeout_ms: z.number().int().positive().default(DEFAULT_ACP_PROMPT_TIMEOUT_MS),
|
|
538
574
|
allow_write_host_services: z.boolean().default(false),
|
|
539
575
|
allow_terminal_host_services: z.boolean().default(false),
|
|
576
|
+
allow_mutating_session_ops: z.boolean().default(false),
|
|
540
577
|
fallback_to_cli_when_unhealthy: z.boolean().default(true),
|
|
541
578
|
providers: z.record(z.string(), AcpProviderSchema).default({}),
|
|
542
579
|
})
|
|
@@ -552,6 +589,7 @@ function defaultAcpConfig(sourcePath) {
|
|
|
552
589
|
promptTimeoutMs: DEFAULT_ACP_PROMPT_TIMEOUT_MS,
|
|
553
590
|
allowWriteHostServices: false,
|
|
554
591
|
allowTerminalHostServices: false,
|
|
592
|
+
allowMutatingSessionOps: false,
|
|
555
593
|
fallbackToCliWhenUnhealthy: true,
|
|
556
594
|
providers: {},
|
|
557
595
|
sources: { configFile: sourcePath },
|
|
@@ -608,11 +646,56 @@ export function loadAcpConfig(logger = noopLogger) {
|
|
|
608
646
|
promptTimeoutMs: parsed.prompt_timeout_ms,
|
|
609
647
|
allowWriteHostServices: parsed.allow_write_host_services,
|
|
610
648
|
allowTerminalHostServices: parsed.allow_terminal_host_services,
|
|
649
|
+
allowMutatingSessionOps: parsed.allow_mutating_session_ops,
|
|
611
650
|
fallbackToCliWhenUnhealthy: parsed.fallback_to_cli_when_unhealthy,
|
|
612
651
|
providers,
|
|
613
652
|
sources: { configFile: sourcePath },
|
|
614
653
|
};
|
|
615
654
|
}
|
|
655
|
+
const AdminConfigSchema = z
|
|
656
|
+
.object({
|
|
657
|
+
allow_mutating_cli_admin_ops: z.boolean().default(false),
|
|
658
|
+
})
|
|
659
|
+
.strict();
|
|
660
|
+
function defaultAdminConfig(sourcePath) {
|
|
661
|
+
return { allowMutatingCliAdminOps: false, sources: { configFile: sourcePath } };
|
|
662
|
+
}
|
|
663
|
+
function readAdminFile(configPath, logger) {
|
|
664
|
+
if (!existsSync(configPath)) {
|
|
665
|
+
return { raw: undefined, sourcePath: null };
|
|
666
|
+
}
|
|
667
|
+
try {
|
|
668
|
+
const require = createRequire(import.meta.url);
|
|
669
|
+
const TOML = require("smol-toml");
|
|
670
|
+
const text = readFileSync(configPath, "utf-8");
|
|
671
|
+
const parsed = TOML.parse(text);
|
|
672
|
+
return { raw: parsed?.admin, sourcePath: configPath };
|
|
673
|
+
}
|
|
674
|
+
catch (err) {
|
|
675
|
+
logger.error(`Failed to parse gateway config at ${configPath}; using admin defaults`, err);
|
|
676
|
+
return { raw: undefined, sourcePath: null };
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
export function loadAdminConfig(logger = noopLogger) {
|
|
680
|
+
const configPath = defaultGatewayConfigPath();
|
|
681
|
+
const { raw, sourcePath } = readAdminFile(configPath, logger);
|
|
682
|
+
if (raw === undefined) {
|
|
683
|
+
return defaultAdminConfig(sourcePath);
|
|
684
|
+
}
|
|
685
|
+
let parsed;
|
|
686
|
+
try {
|
|
687
|
+
parsed = AdminConfigSchema.parse(raw);
|
|
688
|
+
}
|
|
689
|
+
catch (err) {
|
|
690
|
+
throw new Error(`Invalid [admin] config: ${err instanceof Error ? err.message : String(err)}`, {
|
|
691
|
+
cause: err,
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
return {
|
|
695
|
+
allowMutatingCliAdminOps: parsed.allow_mutating_cli_admin_ops,
|
|
696
|
+
sources: { configFile: sourcePath },
|
|
697
|
+
};
|
|
698
|
+
}
|
|
616
699
|
const OAuthRegistrationPolicySchema = z.enum(["static_clients", "shared_secret", "open_dev"]);
|
|
617
700
|
const OAuthClientSchema = z
|
|
618
701
|
.object({
|