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,6 +1,7 @@
1
1
  import type { Logger } from "./logger.js";
2
2
  import type { PersistenceConfig } from "./config.js";
3
- export type JobStoreStatus = "running" | "completed" | "failed" | "canceled" | "orphaned";
3
+ export type JobStoreStatus = "queued" | "running" | "completed" | "failed" | "canceled" | "orphaned";
4
+ export type JobStoreActiveStatus = Extract<JobStoreStatus, "queued" | "running">;
4
5
  export type JobTransport = "process" | "http";
5
6
  export interface JobRecord {
6
7
  id: string;
@@ -23,11 +24,26 @@ export interface JobRecord {
23
24
  transport: JobTransport;
24
25
  httpStatus: number | null;
25
26
  payloadJson: string | null;
27
+ ownerInstance: string | null;
28
+ leaseDeadline: number | null;
26
29
  }
27
30
  export declare function resolveJobStoreDbPath(): string | null;
28
31
  export declare function resolveJobRetentionMs(): number;
29
32
  export declare function resolveDedupWindowMs(): number;
30
33
  export declare function computeRequestKey(cli: string, args: string[], extra?: string): string;
34
+ export interface GatewayInstanceMeta {
35
+ instanceId: string;
36
+ role: string | null;
37
+ hostname: string | null;
38
+ pid: number | null;
39
+ }
40
+ export interface SweepCandidate {
41
+ id: string;
42
+ pid: number | null;
43
+ transport: JobTransport;
44
+ ownerInstance: string | null;
45
+ hostname: string | null;
46
+ }
31
47
  export interface JobStore {
32
48
  recordStart(input: {
33
49
  id: string;
@@ -39,13 +55,23 @@ export interface JobStore {
39
55
  startedAt: string;
40
56
  pid: number | null;
41
57
  ownerPrincipal?: string | null;
58
+ ownerInstance?: string | null;
42
59
  transport?: JobTransport;
43
60
  payloadJson?: string | null;
44
61
  }): void;
62
+ markRunning(id: string, opts: {
63
+ pid: number | null;
64
+ }): boolean;
65
+ registerInstance(meta: GatewayInstanceMeta): void;
66
+ heartbeat(instanceId: string): void;
67
+ deregisterInstance(instanceId: string): void;
68
+ selectStaleProcessCandidates(leaseTtlMs: number, httpJobGraceMs: number): SweepCandidate[];
69
+ recoverStaleJobs(leaseTtlMs: number, httpJobGraceMs: number, liveConfirmedIds?: string[]): OrphanedJobSnapshot[];
70
+ gcInstances(instanceGcMs: number): number;
45
71
  recordOutput(id: string, stdout: string, stderr: string, outputTruncated: boolean): void;
46
72
  recordComplete(input: {
47
73
  id: string;
48
- status: Exclude<JobStoreStatus, "running">;
74
+ status: Exclude<JobStoreStatus, "running" | "queued">;
49
75
  exitCode: number | null;
50
76
  stdout: string;
51
77
  stderr: string;
@@ -117,6 +143,7 @@ export declare class SqliteJobStore implements JobStore, ValidationRunStore {
117
143
  private db;
118
144
  private retentionMs;
119
145
  private dedupWindowMs;
146
+ private leaseTtlMs;
120
147
  private insertStmt;
121
148
  private updateOutputStmt;
122
149
  private updateCompleteStmt;
@@ -125,9 +152,19 @@ export declare class SqliteJobStore implements JobStore, ValidationRunStore {
125
152
  private selectRunningOrphansStmt;
126
153
  private markOrphanedStmt;
127
154
  private deleteExpiredStmt;
155
+ private markRunningStmt;
156
+ private registerInstanceStmt;
157
+ private heartbeatInstanceStmt;
158
+ private heartbeatJobsStmt;
159
+ private deregisterInstanceStmt;
160
+ private selectStaleCandidatesStmt;
161
+ private orphanExpiredStmt;
162
+ private advanceLeaseStmt;
163
+ private gcInstancesStmt;
128
164
  constructor(dbPath: string, logger?: Logger, options?: {
129
165
  retentionMs?: number;
130
166
  dedupWindowMs?: number;
167
+ leaseTtlMs?: number;
131
168
  });
132
169
  recordStart(input: {
133
170
  id: string;
@@ -139,13 +176,23 @@ export declare class SqliteJobStore implements JobStore, ValidationRunStore {
139
176
  startedAt: string;
140
177
  pid: number | null;
141
178
  ownerPrincipal?: string | null;
179
+ ownerInstance?: string | null;
142
180
  transport?: JobTransport;
143
181
  payloadJson?: string | null;
144
182
  }): void;
183
+ markRunning(id: string, opts: {
184
+ pid: number | null;
185
+ }): boolean;
186
+ registerInstance(meta: GatewayInstanceMeta): void;
187
+ heartbeat(instanceId: string): void;
188
+ deregisterInstance(instanceId: string): void;
189
+ selectStaleProcessCandidates(_leaseTtlMs: number, _httpJobGraceMs: number): SweepCandidate[];
190
+ recoverStaleJobs(leaseTtlMs: number, httpJobGraceMs: number, liveConfirmedIds?: string[]): OrphanedJobSnapshot[];
191
+ gcInstances(instanceGcMs: number): number;
145
192
  recordOutput(id: string, stdout: string, stderr: string, outputTruncated: boolean): void;
146
193
  recordComplete(input: {
147
194
  id: string;
148
- status: Exclude<JobStoreStatus, "running">;
195
+ status: Exclude<JobStoreStatus, "running" | "queued">;
149
196
  exitCode: number | null;
150
197
  stdout: string;
151
198
  stderr: string;
@@ -176,9 +223,11 @@ export declare class MemoryJobStore implements JobStore {
176
223
  private rows;
177
224
  private retentionMs;
178
225
  private dedupWindowMs;
226
+ private leaseTtlMs;
179
227
  constructor(options?: {
180
228
  retentionMs?: number;
181
229
  dedupWindowMs?: number;
230
+ leaseTtlMs?: number;
182
231
  });
183
232
  recordStart(input: {
184
233
  id: string;
@@ -190,13 +239,23 @@ export declare class MemoryJobStore implements JobStore {
190
239
  startedAt: string;
191
240
  pid: number | null;
192
241
  ownerPrincipal?: string | null;
242
+ ownerInstance?: string | null;
193
243
  transport?: JobTransport;
194
244
  payloadJson?: string | null;
195
245
  }): void;
246
+ markRunning(id: string, opts: {
247
+ pid: number | null;
248
+ }): boolean;
249
+ registerInstance(_meta: GatewayInstanceMeta): void;
250
+ heartbeat(instanceId: string): void;
251
+ deregisterInstance(_instanceId: string): void;
252
+ selectStaleProcessCandidates(_leaseTtlMs: number, _httpJobGraceMs: number): SweepCandidate[];
253
+ recoverStaleJobs(_leaseTtlMs: number, _httpJobGraceMs: number, _liveConfirmedIds?: string[]): OrphanedJobSnapshot[];
254
+ gcInstances(_instanceGcMs: number): number;
196
255
  recordOutput(id: string, stdout: string, stderr: string, outputTruncated: boolean): void;
197
256
  recordComplete(input: {
198
257
  id: string;
199
- status: Exclude<JobStoreStatus, "running">;
258
+ status: Exclude<JobStoreStatus, "running" | "queued">;
200
259
  exitCode: number | null;
201
260
  stdout: string;
202
261
  stderr: string;
@@ -223,6 +282,7 @@ export declare class PostgresJobStore implements JobStore, ValidationRunStore {
223
282
  constructor(dsn: string, logger?: Logger, options?: {
224
283
  retentionMs?: number;
225
284
  dedupWindowMs?: number;
285
+ leaseTtlMs?: number;
226
286
  });
227
287
  private syncCall;
228
288
  recordStart(input: {
@@ -235,13 +295,23 @@ export declare class PostgresJobStore implements JobStore, ValidationRunStore {
235
295
  startedAt: string;
236
296
  pid: number | null;
237
297
  ownerPrincipal?: string | null;
298
+ ownerInstance?: string | null;
238
299
  transport?: JobTransport;
239
300
  payloadJson?: string | null;
240
301
  }): void;
302
+ markRunning(id: string, opts: {
303
+ pid: number | null;
304
+ }): boolean;
305
+ registerInstance(meta: GatewayInstanceMeta): void;
306
+ heartbeat(instanceId: string): void;
307
+ deregisterInstance(instanceId: string): void;
308
+ selectStaleProcessCandidates(leaseTtlMs: number, httpJobGraceMs: number): SweepCandidate[];
309
+ recoverStaleJobs(leaseTtlMs: number, httpJobGraceMs: number, liveConfirmedIds?: string[]): OrphanedJobSnapshot[];
310
+ gcInstances(instanceGcMs: number): number;
241
311
  recordOutput(id: string, stdout: string, stderr: string, outputTruncated: boolean): void;
242
312
  recordComplete(input: {
243
313
  id: string;
244
- status: Exclude<JobStoreStatus, "running">;
314
+ status: Exclude<JobStoreStatus, "running" | "queued">;
245
315
  exitCode: number | null;
246
316
  stdout: string;
247
317
  stderr: string;
package/dist/job-store.js CHANGED
@@ -6,6 +6,7 @@ import { Worker } from "worker_threads";
6
6
  import { fileURLToPath } from "url";
7
7
  import { openDatabase } from "./sqlite-driver.js";
8
8
  import { noopLogger } from "./logger.js";
9
+ import { DEFAULT_INSTANCE_LEASE_TTL_MS, DEFAULT_HTTP_JOB_GRACE_MS } from "./config.js";
9
10
  export function resolveJobStoreDbPath() {
10
11
  const configured = process.env.LLM_GATEWAY_JOBS_DB ?? process.env.LLM_GATEWAY_LOGS_DB;
11
12
  if (configured !== undefined) {
@@ -19,6 +20,7 @@ export function resolveJobStoreDbPath() {
19
20
  }
20
21
  const DEFAULT_RETENTION_DAYS = 30;
21
22
  const FAR_FUTURE_ISO = "9999-12-31T23:59:59.999Z";
23
+ const SQLITE_NOW_MS = "CAST(ROUND((julianday('now') - 2440587.5) * 86400000.0) AS INTEGER)";
22
24
  export function resolveJobRetentionMs() {
23
25
  const raw = process.env.LLM_GATEWAY_JOB_RETENTION_DAYS;
24
26
  const days = raw ? Number(raw) : DEFAULT_RETENTION_DAYS;
@@ -63,6 +65,8 @@ function rowToRecord(row) {
63
65
  transport: row.transport ?? "process",
64
66
  httpStatus: row.http_status ?? null,
65
67
  payloadJson: row.payload_json ?? null,
68
+ ownerInstance: row.owner_instance ?? null,
69
+ leaseDeadline: row.lease_deadline == null ? null : Number(row.lease_deadline),
66
70
  };
67
71
  }
68
72
  function ensureJobsOwnerColumn(db) {
@@ -85,6 +89,16 @@ function ensureJobsTransportColumns(db) {
85
89
  db.exec("ALTER TABLE jobs ADD COLUMN payload_json TEXT");
86
90
  }
87
91
  }
92
+ function ensureJobsLeaseColumns(db) {
93
+ const cols = db.prepare("PRAGMA table_info(jobs)").all();
94
+ const names = new Set(cols.map(col => col?.name));
95
+ if (!names.has("owner_instance")) {
96
+ db.exec("ALTER TABLE jobs ADD COLUMN owner_instance TEXT");
97
+ }
98
+ if (!names.has("lease_deadline")) {
99
+ db.exec("ALTER TABLE jobs ADD COLUMN lease_deadline INTEGER");
100
+ }
101
+ }
88
102
  export function isValidationRunStore(store) {
89
103
  return (typeof store === "object" &&
90
104
  store !== null &&
@@ -97,6 +111,7 @@ export class SqliteJobStore {
97
111
  db;
98
112
  retentionMs;
99
113
  dedupWindowMs;
114
+ leaseTtlMs;
100
115
  insertStmt;
101
116
  updateOutputStmt;
102
117
  updateCompleteStmt;
@@ -105,11 +120,21 @@ export class SqliteJobStore {
105
120
  selectRunningOrphansStmt;
106
121
  markOrphanedStmt;
107
122
  deleteExpiredStmt;
123
+ markRunningStmt;
124
+ registerInstanceStmt;
125
+ heartbeatInstanceStmt;
126
+ heartbeatJobsStmt;
127
+ deregisterInstanceStmt;
128
+ selectStaleCandidatesStmt;
129
+ orphanExpiredStmt;
130
+ advanceLeaseStmt;
131
+ gcInstancesStmt;
108
132
  constructor(dbPath, logger = noopLogger, options = {}) {
109
133
  this.logger = logger;
110
134
  this.db = openDatabase(dbPath);
111
135
  this.db.exec("PRAGMA journal_mode = WAL");
112
136
  this.db.exec("PRAGMA synchronous = NORMAL");
137
+ this.db.exec("PRAGMA busy_timeout = 5000");
113
138
  this.db.exec(`
114
139
  CREATE TABLE IF NOT EXISTS jobs (
115
140
  id TEXT PRIMARY KEY,
@@ -131,12 +156,25 @@ export class SqliteJobStore {
131
156
  owner_principal TEXT,
132
157
  transport TEXT NOT NULL DEFAULT 'process',
133
158
  http_status INTEGER,
134
- payload_json TEXT
159
+ payload_json TEXT,
160
+ owner_instance TEXT,
161
+ lease_deadline INTEGER
135
162
  );
136
163
  CREATE INDEX IF NOT EXISTS idx_jobs_request_key ON jobs(request_key);
137
164
  CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status);
138
165
  CREATE INDEX IF NOT EXISTS idx_jobs_expires_at ON jobs(expires_at);
139
166
  CREATE INDEX IF NOT EXISTS idx_jobs_request_key_finished ON jobs(request_key, finished_at);
167
+
168
+ CREATE TABLE IF NOT EXISTS gateway_instances (
169
+ instance_id TEXT PRIMARY KEY,
170
+ role TEXT,
171
+ hostname TEXT,
172
+ pid INTEGER,
173
+ started_at INTEGER NOT NULL,
174
+ last_heartbeat INTEGER NOT NULL
175
+ );
176
+ CREATE INDEX IF NOT EXISTS idx_gateway_instances_heartbeat
177
+ ON gateway_instances(last_heartbeat);
140
178
  `);
141
179
  this.db.exec(`
142
180
  CREATE TABLE IF NOT EXISTS validation_runs (
@@ -176,6 +214,8 @@ export class SqliteJobStore {
176
214
  `);
177
215
  ensureJobsOwnerColumn(this.db);
178
216
  ensureJobsTransportColumns(this.db);
217
+ ensureJobsLeaseColumns(this.db);
218
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_jobs_owner_status ON jobs(owner_instance, status)");
179
219
  if (process.platform !== "win32") {
180
220
  try {
181
221
  chmodSync(dbPath, 0o600);
@@ -185,15 +225,17 @@ export class SqliteJobStore {
185
225
  }
186
226
  this.retentionMs = options.retentionMs ?? resolveJobRetentionMs();
187
227
  this.dedupWindowMs = options.dedupWindowMs ?? resolveDedupWindowMs();
228
+ this.leaseTtlMs = options.leaseTtlMs ?? DEFAULT_INSTANCE_LEASE_TTL_MS;
188
229
  this.insertStmt = this.db.prepare(`
189
230
  INSERT INTO jobs (id, correlation_id, request_key, cli, args_json, output_format,
190
231
  status, exit_code, stdout, stderr, output_truncated, error,
191
232
  started_at, finished_at, pid, expires_at, owner_principal,
192
- transport, http_status, payload_json)
233
+ transport, http_status, payload_json, owner_instance, lease_deadline)
193
234
  VALUES (@id, @correlation_id, @request_key, @cli, @args_json, @output_format,
194
- @status, @exit_code, @stdout, @stderr, @output_truncated, @error,
235
+ 'queued', @exit_code, @stdout, @stderr, @output_truncated, @error,
195
236
  @started_at, @finished_at, @pid, @expires_at, @owner_principal,
196
- @transport, @http_status, @payload_json)
237
+ @transport, @http_status, @payload_json, @owner_instance,
238
+ ${SQLITE_NOW_MS} + @lease_ttl_ms)
197
239
  `);
198
240
  this.updateOutputStmt = this.db.prepare(`
199
241
  UPDATE jobs SET stdout = @stdout, stderr = @stderr, output_truncated = @output_truncated
@@ -203,15 +245,18 @@ export class SqliteJobStore {
203
245
  UPDATE jobs SET status = @status, exit_code = @exit_code, stdout = @stdout, stderr = @stderr,
204
246
  output_truncated = @output_truncated, error = @error,
205
247
  finished_at = @finished_at, expires_at = @expires_at,
206
- http_status = @http_status
207
- WHERE id = @id
248
+ http_status = @http_status, lease_deadline = NULL
249
+ WHERE id = @id AND status IN ('queued', 'running', 'orphaned')
208
250
  `);
209
251
  this.getByIdStmt = this.db.prepare(`SELECT * FROM jobs WHERE id = ?`);
210
252
  this.findByRequestKeyStmt = this.db.prepare(`
211
253
  SELECT * FROM jobs
212
254
  WHERE request_key = ?
213
255
  AND started_at >= ?
214
- AND status IN ('running', 'completed')
256
+ AND (
257
+ status IN ('running', 'completed')
258
+ OR (status = 'queued' AND lease_deadline IS NOT NULL AND lease_deadline >= ${SQLITE_NOW_MS})
259
+ )
215
260
  ORDER BY started_at DESC
216
261
  LIMIT 1
217
262
  `);
@@ -228,6 +273,56 @@ export class SqliteJobStore {
228
273
  WHERE status = 'running'
229
274
  `);
230
275
  this.deleteExpiredStmt = this.db.prepare(`DELETE FROM jobs WHERE expires_at < ?`);
276
+ this.markRunningStmt = this.db.prepare(`
277
+ UPDATE jobs
278
+ SET status = 'running', pid = @pid, lease_deadline = ${SQLITE_NOW_MS} + @lease_ttl_ms
279
+ WHERE id = @id AND status = 'queued'
280
+ `);
281
+ this.registerInstanceStmt = this.db.prepare(`
282
+ INSERT INTO gateway_instances (instance_id, role, hostname, pid, started_at, last_heartbeat)
283
+ VALUES (@instance_id, @role, @hostname, @pid, ${SQLITE_NOW_MS}, ${SQLITE_NOW_MS})
284
+ ON CONFLICT(instance_id) DO UPDATE SET
285
+ role = excluded.role, hostname = excluded.hostname, pid = excluded.pid,
286
+ last_heartbeat = excluded.last_heartbeat
287
+ `);
288
+ this.heartbeatInstanceStmt = this.db.prepare(`
289
+ UPDATE gateway_instances SET last_heartbeat = ${SQLITE_NOW_MS} WHERE instance_id = @instance_id
290
+ `);
291
+ this.heartbeatJobsStmt = this.db.prepare(`
292
+ UPDATE jobs SET lease_deadline = ${SQLITE_NOW_MS} + @lease_ttl_ms
293
+ WHERE owner_instance = @instance_id AND status IN ('queued', 'running')
294
+ `);
295
+ this.deregisterInstanceStmt = this.db.prepare(`DELETE FROM gateway_instances WHERE instance_id = @instance_id`);
296
+ this.selectStaleCandidatesStmt = this.db.prepare(`
297
+ SELECT j.id AS id, j.pid AS pid, j.transport AS transport,
298
+ j.owner_instance AS owner_instance, gi.hostname AS hostname
299
+ FROM jobs j
300
+ LEFT JOIN gateway_instances gi ON gi.instance_id = j.owner_instance
301
+ WHERE j.status IN ('queued', 'running')
302
+ AND j.transport = 'process'
303
+ AND j.pid IS NOT NULL
304
+ AND (j.lease_deadline IS NULL OR j.lease_deadline < ${SQLITE_NOW_MS})
305
+ `);
306
+ this.orphanExpiredStmt = this.db.prepare(`
307
+ UPDATE jobs
308
+ SET status = 'orphaned',
309
+ error = COALESCE(error, 'owning gateway instance is no longer alive'),
310
+ finished_at = COALESCE(finished_at, @now_iso),
311
+ expires_at = @expires_iso,
312
+ lease_deadline = NULL
313
+ WHERE status IN ('queued', 'running')
314
+ AND (lease_deadline IS NULL OR lease_deadline < ${SQLITE_NOW_MS})
315
+ AND (transport <> 'http'
316
+ OR started_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', @http_grace_modifier))
317
+ AND id NOT IN (SELECT value FROM json_each(@exclude_json))
318
+ RETURNING id, correlation_id, started_at, stdout, stderr, exit_code, transport, http_status
319
+ `);
320
+ this.advanceLeaseStmt = this.db.prepare(`
321
+ UPDATE jobs SET lease_deadline = ${SQLITE_NOW_MS} + @lease_ttl_ms, pid = NULL
322
+ WHERE status IN ('queued', 'running')
323
+ AND id IN (SELECT value FROM json_each(@ids_json))
324
+ `);
325
+ this.gcInstancesStmt = this.db.prepare(`DELETE FROM gateway_instances WHERE last_heartbeat < ${SQLITE_NOW_MS} - @gc_ms`);
231
326
  }
232
327
  recordStart(input) {
233
328
  this.insertStmt.run({
@@ -237,7 +332,6 @@ export class SqliteJobStore {
237
332
  cli: input.cli,
238
333
  args_json: JSON.stringify(input.args),
239
334
  output_format: input.outputFormat ?? null,
240
- status: "running",
241
335
  exit_code: null,
242
336
  stdout: "",
243
337
  stderr: "",
@@ -251,7 +345,74 @@ export class SqliteJobStore {
251
345
  transport: input.transport ?? "process",
252
346
  http_status: null,
253
347
  payload_json: input.payloadJson ?? null,
348
+ owner_instance: input.ownerInstance ?? null,
349
+ lease_ttl_ms: this.leaseTtlMs,
350
+ });
351
+ }
352
+ markRunning(id, opts) {
353
+ const result = this.markRunningStmt.run({
354
+ id,
355
+ pid: opts.pid,
356
+ lease_ttl_ms: this.leaseTtlMs,
357
+ });
358
+ return Number(result.changes) > 0;
359
+ }
360
+ registerInstance(meta) {
361
+ this.registerInstanceStmt.run({
362
+ instance_id: meta.instanceId,
363
+ role: meta.role ?? null,
364
+ hostname: meta.hostname ?? null,
365
+ pid: meta.pid ?? null,
366
+ });
367
+ }
368
+ heartbeat(instanceId) {
369
+ this.heartbeatInstanceStmt.run({ instance_id: instanceId });
370
+ this.heartbeatJobsStmt.run({ instance_id: instanceId, lease_ttl_ms: this.leaseTtlMs });
371
+ }
372
+ deregisterInstance(instanceId) {
373
+ this.deregisterInstanceStmt.run({ instance_id: instanceId });
374
+ }
375
+ selectStaleProcessCandidates(_leaseTtlMs, _httpJobGraceMs) {
376
+ const rows = this.selectStaleCandidatesStmt.all();
377
+ return rows.map(r => ({
378
+ id: r.id,
379
+ pid: r.pid,
380
+ transport: r.transport ?? "process",
381
+ ownerInstance: r.owner_instance ?? null,
382
+ hostname: r.hostname ?? null,
383
+ }));
384
+ }
385
+ recoverStaleJobs(leaseTtlMs, httpJobGraceMs, liveConfirmedIds = []) {
386
+ const excludeJson = JSON.stringify(liveConfirmedIds);
387
+ const httpGraceModifier = `-${httpJobGraceMs / 1000} seconds`;
388
+ const run = this.db.withTransaction(() => {
389
+ if (liveConfirmedIds.length > 0) {
390
+ this.advanceLeaseStmt.run({ ids_json: excludeJson, lease_ttl_ms: leaseTtlMs });
391
+ }
392
+ const nowIso = new Date().toISOString();
393
+ const expiresAt = new Date(Date.now() + this.retentionMs).toISOString();
394
+ const rows = this.orphanExpiredStmt.all({
395
+ now_iso: nowIso,
396
+ expires_iso: expiresAt,
397
+ http_grace_modifier: httpGraceModifier,
398
+ exclude_json: excludeJson,
399
+ });
400
+ return rows.map(r => ({
401
+ id: r.id,
402
+ correlationId: r.correlation_id,
403
+ startedAt: r.started_at,
404
+ stdout: r.stdout ?? "",
405
+ stderr: r.stderr ?? "",
406
+ exitCode: r.exit_code,
407
+ transport: r.transport ?? "process",
408
+ httpStatus: r.http_status ?? null,
409
+ }));
254
410
  });
411
+ return run();
412
+ }
413
+ gcInstances(instanceGcMs) {
414
+ const result = this.gcInstancesStmt.run({ gc_ms: instanceGcMs });
415
+ return Number(result.changes);
255
416
  }
256
417
  recordOutput(id, stdout, stderr, outputTruncated) {
257
418
  this.updateOutputStmt.run({
@@ -286,21 +447,8 @@ export class SqliteJobStore {
286
447
  return row ? rowToRecord(row) : null;
287
448
  }
288
449
  markOrphanedOnStartup() {
289
- const now = new Date().toISOString();
290
- const expiresAt = new Date(Date.now() + this.retentionMs).toISOString();
291
- const rows = this.selectRunningOrphansStmt.all();
292
- const orphaned = rows.map(row => ({
293
- id: row.id,
294
- correlationId: row.correlation_id,
295
- startedAt: row.started_at,
296
- stdout: row.stdout ?? "",
297
- stderr: row.stderr ?? "",
298
- exitCode: row.exit_code,
299
- transport: row.transport ?? "process",
300
- httpStatus: row.http_status ?? null,
301
- }));
302
- const result = this.markOrphanedStmt.run(now, expiresAt);
303
- return { count: Number(result.changes), orphaned };
450
+ const orphaned = this.recoverStaleJobs(this.leaseTtlMs, DEFAULT_HTTP_JOB_GRACE_MS);
451
+ return { count: orphaned.length, orphaned };
304
452
  }
305
453
  evictExpired() {
306
454
  const now = new Date().toISOString();
@@ -462,9 +610,11 @@ export class MemoryJobStore {
462
610
  rows = new Map();
463
611
  retentionMs;
464
612
  dedupWindowMs;
613
+ leaseTtlMs;
465
614
  constructor(options = {}) {
466
615
  this.retentionMs = options.retentionMs ?? resolveJobRetentionMs();
467
616
  this.dedupWindowMs = options.dedupWindowMs ?? resolveDedupWindowMs();
617
+ this.leaseTtlMs = options.leaseTtlMs ?? DEFAULT_INSTANCE_LEASE_TTL_MS;
468
618
  }
469
619
  recordStart(input) {
470
620
  this.rows.set(input.id, {
@@ -474,7 +624,7 @@ export class MemoryJobStore {
474
624
  cli: input.cli,
475
625
  argsJson: JSON.stringify(input.args),
476
626
  outputFormat: input.outputFormat ?? null,
477
- status: "running",
627
+ status: "queued",
478
628
  exitCode: null,
479
629
  stdout: "",
480
630
  stderr: "",
@@ -488,8 +638,39 @@ export class MemoryJobStore {
488
638
  transport: input.transport ?? "process",
489
639
  httpStatus: null,
490
640
  payloadJson: input.payloadJson ?? null,
641
+ ownerInstance: input.ownerInstance ?? null,
642
+ leaseDeadline: Date.now() + this.leaseTtlMs,
491
643
  });
492
644
  }
645
+ markRunning(id, opts) {
646
+ const row = this.rows.get(id);
647
+ if (!row || row.status !== "queued")
648
+ return false;
649
+ row.status = "running";
650
+ row.pid = opts.pid;
651
+ row.leaseDeadline = Date.now() + this.leaseTtlMs;
652
+ return true;
653
+ }
654
+ registerInstance(_meta) { }
655
+ heartbeat(instanceId) {
656
+ const deadline = Date.now() + this.leaseTtlMs;
657
+ for (const row of this.rows.values()) {
658
+ if (row.ownerInstance === instanceId &&
659
+ (row.status === "queued" || row.status === "running")) {
660
+ row.leaseDeadline = deadline;
661
+ }
662
+ }
663
+ }
664
+ deregisterInstance(_instanceId) { }
665
+ selectStaleProcessCandidates(_leaseTtlMs, _httpJobGraceMs) {
666
+ return [];
667
+ }
668
+ recoverStaleJobs(_leaseTtlMs, _httpJobGraceMs, _liveConfirmedIds) {
669
+ return [];
670
+ }
671
+ gcInstances(_instanceGcMs) {
672
+ return 0;
673
+ }
493
674
  recordOutput(id, stdout, stderr, outputTruncated) {
494
675
  const row = this.rows.get(id);
495
676
  if (!row)
@@ -502,6 +683,8 @@ export class MemoryJobStore {
502
683
  const row = this.rows.get(input.id);
503
684
  if (!row)
504
685
  return;
686
+ if (row.status !== "queued" && row.status !== "running" && row.status !== "orphaned")
687
+ return;
505
688
  row.status = input.status;
506
689
  row.exitCode = input.exitCode;
507
690
  row.stdout = input.stdout;
@@ -510,6 +693,7 @@ export class MemoryJobStore {
510
693
  row.error = input.error;
511
694
  row.finishedAt = input.finishedAt;
512
695
  row.expiresAt = new Date(Date.parse(input.finishedAt) + this.retentionMs).toISOString();
696
+ row.leaseDeadline = null;
513
697
  if (input.httpStatus !== undefined)
514
698
  row.httpStatus = input.httpStatus;
515
699
  }
@@ -519,11 +703,15 @@ export class MemoryJobStore {
519
703
  }
520
704
  findByRequestKey(requestKey) {
521
705
  const cutoffMs = Date.now() - this.dedupWindowMs;
706
+ const nowMs = Date.now();
522
707
  let best = null;
523
708
  for (const row of this.rows.values()) {
524
709
  if (row.requestKey !== requestKey)
525
710
  continue;
526
- if (row.status !== "running" && row.status !== "completed")
711
+ const reusable = row.status === "running" ||
712
+ row.status === "completed" ||
713
+ (row.status === "queued" && row.leaseDeadline !== null && row.leaseDeadline >= nowMs);
714
+ if (!reusable)
527
715
  continue;
528
716
  if (Date.parse(row.startedAt) < cutoffMs)
529
717
  continue;
@@ -569,6 +757,7 @@ export class PostgresJobStore {
569
757
  dsn,
570
758
  retentionMs: options.retentionMs ?? resolveJobRetentionMs(),
571
759
  dedupWindowMs: options.dedupWindowMs ?? resolveDedupWindowMs(),
760
+ leaseTtlMs: options.leaseTtlMs ?? DEFAULT_INSTANCE_LEASE_TTL_MS,
572
761
  farFutureIso: FAR_FUTURE_ISO,
573
762
  connectionTimeoutMillis: 5000,
574
763
  },
@@ -616,6 +805,37 @@ export class PostgresJobStore {
616
805
  recordStart(input) {
617
806
  this.syncCall("recordStart", input);
618
807
  }
808
+ markRunning(id, opts) {
809
+ return this.syncCall("markRunning", id, opts);
810
+ }
811
+ registerInstance(meta) {
812
+ this.syncCall("registerInstance", meta);
813
+ }
814
+ heartbeat(instanceId) {
815
+ this.syncCall("heartbeat", instanceId);
816
+ }
817
+ deregisterInstance(instanceId) {
818
+ this.syncCall("deregisterInstance", instanceId);
819
+ }
820
+ selectStaleProcessCandidates(leaseTtlMs, httpJobGraceMs) {
821
+ return this.syncCall("selectStaleProcessCandidates", leaseTtlMs, httpJobGraceMs);
822
+ }
823
+ recoverStaleJobs(leaseTtlMs, httpJobGraceMs, liveConfirmedIds = []) {
824
+ const result = this.syncCall("recoverStaleJobs", leaseTtlMs, httpJobGraceMs, liveConfirmedIds);
825
+ return result.orphaned.map(row => ({
826
+ id: row.id,
827
+ correlationId: row.correlation_id,
828
+ startedAt: row.started_at,
829
+ stdout: row.stdout ?? "",
830
+ stderr: row.stderr ?? "",
831
+ exitCode: row.exit_code,
832
+ transport: row.transport ?? "process",
833
+ httpStatus: row.http_status ?? null,
834
+ }));
835
+ }
836
+ gcInstances(instanceGcMs) {
837
+ return this.syncCall("gcInstances", instanceGcMs);
838
+ }
619
839
  recordOutput(id, stdout, stderr, outputTruncated) {
620
840
  this.syncCall("recordOutput", id, stdout, stderr, outputTruncated);
621
841
  }
@@ -631,20 +851,8 @@ export class PostgresJobStore {
631
851
  return row ? rowToRecord(row) : null;
632
852
  }
633
853
  markOrphanedOnStartup() {
634
- const result = this.syncCall("markOrphanedOnStartup");
635
- return {
636
- count: result.count,
637
- orphaned: result.orphaned.map(row => ({
638
- id: row.id,
639
- correlationId: row.correlation_id,
640
- startedAt: row.started_at,
641
- stdout: row.stdout ?? "",
642
- stderr: row.stderr ?? "",
643
- exitCode: row.exit_code,
644
- transport: row.transport ?? "process",
645
- httpStatus: row.http_status ?? null,
646
- })),
647
- };
854
+ const orphaned = this.recoverStaleJobs(DEFAULT_INSTANCE_LEASE_TTL_MS, DEFAULT_HTTP_JOB_GRACE_MS);
855
+ return { count: orphaned.length, orphaned };
648
856
  }
649
857
  evictExpired() {
650
858
  return this.syncCall("evictExpired");
@@ -703,6 +911,7 @@ export function createJobStore(config, logger = noopLogger) {
703
911
  const opts = {
704
912
  retentionMs: config.retentionDays * 24 * 60 * 60 * 1000,
705
913
  dedupWindowMs: config.dedupWindowMs,
914
+ leaseTtlMs: config.instanceLeaseTtlMs,
706
915
  };
707
916
  switch (config.backend) {
708
917
  case "none":