@akira-tl/forgerelay 0.4.6 → 0.5.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.
@@ -6,21 +6,33 @@ const DEFAULT_POLL_YIELD_MS = 5_000;
6
6
  const MAX_START_YIELD_MS = 300_000;
7
7
  const MAX_COMMAND_YIELD_MS = 300_000;
8
8
  const MAX_POLL_YIELD_MS = 300_000;
9
+ const MAX_EXECUTION_TIMEOUT_MS = 24 * 60 * 60 * 1_000;
9
10
  const DEFAULT_MAX_OUTPUT_TOKENS = 10_000;
10
11
  const DEFAULT_BUFFER_CHARACTERS = 256_000;
11
12
  const DEFAULT_MAX_ACTIVE_PROCESSES = 64;
12
13
  const DEFAULT_MAX_COMPLETED_PROCESSES = 128;
13
14
  const COMPLETED_PROCESS_TTL_MS = 5 * 60 * 1_000;
15
+ const COMPACT_COMPLETION_TTL_MS = 24 * 60 * 60 * 1_000;
16
+ const COMPACT_COMPLETION_CHARACTERS = 16 * 1024;
17
+ const COMPACT_COMPLETION_OUTPUT_TOKENS = COMPACT_COMPLETION_CHARACTERS / 4;
14
18
  const DEFAULT_COLUMNS = 80;
15
19
  const DEFAULT_ROWS = 24;
16
20
  function boundedInteger(value, fallback, maximum) {
17
21
  if (value === undefined)
18
- return fallback;
22
+ return Math.min(fallback, maximum);
19
23
  if (!Number.isFinite(value) || value < 0) {
20
24
  throw new Error("Duration and output limits must be non-negative.");
21
25
  }
22
26
  return Math.min(Math.floor(value), maximum);
23
27
  }
28
+ function optionalExecutionTimeout(value) {
29
+ if (value === undefined)
30
+ return undefined;
31
+ if (!Number.isInteger(value) || value < 1 || value > MAX_EXECUTION_TIMEOUT_MS) {
32
+ throw new Error(`Execution timeout must be an integer between 1 and ${MAX_EXECUTION_TIMEOUT_MS}ms.`);
33
+ }
34
+ return value;
35
+ }
24
36
  function terminalSize(value, fallback) {
25
37
  if (value === undefined)
26
38
  return fallback;
@@ -215,9 +227,11 @@ export class ProcessManager {
215
227
  this.monotonicNow = options.monotonicNow ?? (() => performance.now());
216
228
  }
217
229
  async start(input) {
230
+ input.signal?.throwIfAborted();
218
231
  if (this.stats().running >= this.maxActiveProcesses) {
219
232
  throw new Error(`Active process limit reached (${this.maxActiveProcesses}). Poll, interrupt, or wait for an existing process before starting another.`);
220
233
  }
234
+ const executionTimeoutMs = optionalExecutionTimeout(input.timeoutMs);
221
235
  const processEntry = this.createProcess(input);
222
236
  this.processes.set(processEntry.id, processEntry);
223
237
  try {
@@ -230,8 +244,19 @@ export class ProcessManager {
230
244
  this.processes.delete(processEntry.id);
231
245
  throw error;
232
246
  }
247
+ this.armExecutionTimeout(processEntry, executionTimeoutMs);
233
248
  const yieldTimeMs = boundedInteger(input.yieldTimeMs, DEFAULT_EXEC_YIELD_MS, this.maxStartYieldMs);
234
- await this.waitForExit(processEntry, yieldTimeMs);
249
+ try {
250
+ await this.waitForExit(processEntry, yieldTimeMs, input.signal);
251
+ input.signal?.throwIfAborted();
252
+ }
253
+ catch (error) {
254
+ if (input.signal?.aborted) {
255
+ processEntry.discardOnFinish = true;
256
+ this.stopProcess(processEntry, "SIGTERM");
257
+ }
258
+ throw error;
259
+ }
235
260
  if (processEntry.running)
236
261
  processEntry.background = true;
237
262
  const snapshot = this.consume(processEntry, input.maxOutputTokens);
@@ -263,7 +288,7 @@ export class ProcessManager {
263
288
  const fallback = interactionRequested ? DEFAULT_INTERACTIVE_YIELD_MS : DEFAULT_POLL_YIELD_MS;
264
289
  const maximum = interactionRequested ? MAX_COMMAND_YIELD_MS : MAX_POLL_YIELD_MS;
265
290
  const yieldTimeMs = boundedInteger(input.yieldTimeMs, fallback, maximum);
266
- await this.waitForExit(processEntry, yieldTimeMs);
291
+ await this.waitForExit(processEntry, yieldTimeMs, input.signal);
267
292
  }
268
293
  const snapshot = this.consume(processEntry, input.maxOutputTokens);
269
294
  if (!processEntry.running)
@@ -271,7 +296,9 @@ export class ProcessManager {
271
296
  return snapshot;
272
297
  }
273
298
  activeWorkspaceIds() {
274
- return new Set([...this.processes.values()].map((processEntry) => processEntry.workspaceId));
299
+ return new Set([...this.processes.values()]
300
+ .filter((processEntry) => processEntry.running)
301
+ .map((processEntry) => processEntry.workspaceId));
275
302
  }
276
303
  stats() {
277
304
  let running = 0;
@@ -311,10 +338,17 @@ export class ProcessManager {
311
338
  if (processEntry.running)
312
339
  processEntry.process?.kill("SIGTERM");
313
340
  }
341
+ discardUndelivered(workspaceId, processId) {
342
+ const processEntry = this.getOwnedProcess(workspaceId, processId);
343
+ processEntry.discardOnFinish = true;
344
+ if (processEntry.running)
345
+ this.stopProcess(processEntry, "SIGTERM");
346
+ else
347
+ this.removeProcess(processEntry.id);
348
+ }
314
349
  shutdown() {
315
350
  for (const processEntry of this.processes.values()) {
316
- if (processEntry.cleanupTimer)
317
- clearTimeout(processEntry.cleanupTimer);
351
+ this.clearProcessTimers(processEntry);
318
352
  if (processEntry.running)
319
353
  processEntry.process?.kill("SIGTERM");
320
354
  }
@@ -322,19 +356,32 @@ export class ProcessManager {
322
356
  this.completedByWorkspace.clear();
323
357
  this.completedProcessIds.length = 0;
324
358
  }
325
- async waitForExit(processEntry, yieldTimeMs) {
359
+ async waitForExit(processEntry, yieldTimeMs, signal) {
360
+ signal?.throwIfAborted();
326
361
  let timer;
362
+ let abortListener;
327
363
  try {
328
- await Promise.race([
364
+ const waits = [
329
365
  processEntry.exitPromise,
330
366
  new Promise((resolve) => {
331
367
  timer = setTimeout(resolve, yieldTimeMs);
332
368
  }),
333
- ]);
369
+ ];
370
+ if (signal) {
371
+ waits.push(new Promise((_resolve, reject) => {
372
+ abortListener = () => reject(signal.reason instanceof Error
373
+ ? signal.reason
374
+ : Object.assign(new Error("Process wait cancelled by Host."), { name: "AbortError" }));
375
+ signal.addEventListener("abort", abortListener, { once: true });
376
+ }));
377
+ }
378
+ await Promise.race(waits);
334
379
  }
335
380
  finally {
336
381
  if (timer)
337
382
  clearTimeout(timer);
383
+ if (signal && abortListener)
384
+ signal.removeEventListener("abort", abortListener);
338
385
  }
339
386
  }
340
387
  createProcess(input) {
@@ -351,7 +398,10 @@ export class ProcessManager {
351
398
  rows: terminalSize(input.rows, DEFAULT_ROWS),
352
399
  buffer: new HeadTailBuffer(this.maxBufferCharacters),
353
400
  running: true,
401
+ timedOut: false,
402
+ outputWasTruncated: false,
354
403
  background: false,
404
+ discardOnFinish: false,
355
405
  exitPromise,
356
406
  resolveExit,
357
407
  };
@@ -423,9 +473,18 @@ export class ProcessManager {
423
473
  processEntry.running = false;
424
474
  processEntry.exitCode = exitCode;
425
475
  processEntry.signal = signal;
476
+ processEntry.finishedAtMonotonic = this.monotonicNow();
426
477
  processEntry.process = undefined;
478
+ if (processEntry.executionTimeoutTimer)
479
+ clearTimeout(processEntry.executionTimeoutTimer);
480
+ if (processEntry.forceKillTimer)
481
+ clearTimeout(processEntry.forceKillTimer);
427
482
  processEntry.resolveExit();
428
- processEntry.cleanupTimer = setTimeout(() => this.removeProcess(processEntry.id), this.completedProcessTtlMs);
483
+ if (processEntry.discardOnFinish) {
484
+ this.removeProcess(processEntry.id);
485
+ return;
486
+ }
487
+ processEntry.cleanupTimer = setTimeout(() => this.compactCompletedProcess(processEntry), this.completedProcessTtlMs);
429
488
  processEntry.cleanupTimer.unref();
430
489
  if (processEntry.background) {
431
490
  const completed = this.completedByWorkspace.get(processEntry.workspaceId) ?? [];
@@ -442,6 +501,17 @@ export class ProcessManager {
442
501
  }
443
502
  }
444
503
  }
504
+ compactCompletedProcess(processEntry) {
505
+ if (processEntry.running || !this.processes.has(processEntry.id))
506
+ return;
507
+ const compacted = this.consume(processEntry, COMPACT_COMPLETION_OUTPUT_TOKENS);
508
+ processEntry.buffer = new HeadTailBuffer(COMPACT_COMPLETION_CHARACTERS);
509
+ processEntry.buffer.append(compacted.output);
510
+ processEntry.outputWasTruncated = compacted.outputTruncated;
511
+ const remainingMs = Math.max(1, COMPACT_COMPLETION_TTL_MS - this.completedProcessTtlMs);
512
+ processEntry.cleanupTimer = setTimeout(() => this.removeProcess(processEntry.id), remainingMs);
513
+ processEntry.cleanupTimer.unref();
514
+ }
445
515
  append(processEntry, output) {
446
516
  processEntry.buffer.append(output);
447
517
  }
@@ -449,18 +519,53 @@ export class ProcessManager {
449
519
  const limit = boundedInteger(maxOutputTokens, DEFAULT_MAX_OUTPUT_TOKENS, 100_000);
450
520
  const maxCharacters = Math.max(256, limit * 4);
451
521
  const buffered = processEntry.buffer.drain(maxCharacters);
522
+ if (buffered.truncated)
523
+ processEntry.outputWasTruncated = true;
452
524
  const processId = processEntry.running ? processEntry.id : undefined;
525
+ const endedAt = processEntry.finishedAtMonotonic ?? this.monotonicNow();
453
526
  return {
454
527
  processId,
455
528
  sessionId: processId,
456
529
  output: buffered.output,
457
- outputTruncated: buffered.truncated,
530
+ outputTruncated: processEntry.outputWasTruncated,
458
531
  running: processEntry.running,
459
532
  exitCode: processEntry.exitCode,
460
533
  signal: processEntry.signal,
461
- wallTimeMs: Math.max(0, Math.round(this.monotonicNow() - processEntry.startedAtMonotonic)),
534
+ timedOut: processEntry.timedOut,
535
+ wallTimeMs: Math.max(0, Math.round(endedAt - processEntry.startedAtMonotonic)),
462
536
  };
463
537
  }
538
+ armExecutionTimeout(processEntry, timeoutMs) {
539
+ if (timeoutMs === undefined)
540
+ return;
541
+ processEntry.executionTimeoutTimer = setTimeout(() => {
542
+ if (!processEntry.running)
543
+ return;
544
+ processEntry.timedOut = true;
545
+ this.stopProcess(processEntry, "SIGTERM");
546
+ }, timeoutMs);
547
+ processEntry.executionTimeoutTimer.unref();
548
+ }
549
+ stopProcess(processEntry, signal) {
550
+ if (!processEntry.running)
551
+ return;
552
+ processEntry.process?.kill(signal);
553
+ if (processEntry.forceKillTimer)
554
+ clearTimeout(processEntry.forceKillTimer);
555
+ processEntry.forceKillTimer = setTimeout(() => {
556
+ if (processEntry.running)
557
+ processEntry.process?.kill("SIGKILL");
558
+ }, 500);
559
+ processEntry.forceKillTimer.unref();
560
+ }
561
+ clearProcessTimers(processEntry) {
562
+ if (processEntry.cleanupTimer)
563
+ clearTimeout(processEntry.cleanupTimer);
564
+ if (processEntry.executionTimeoutTimer)
565
+ clearTimeout(processEntry.executionTimeoutTimer);
566
+ if (processEntry.forceKillTimer)
567
+ clearTimeout(processEntry.forceKillTimer);
568
+ }
464
569
  getOwnedProcess(workspaceId, processId) {
465
570
  const processEntry = this.processes.get(processId);
466
571
  if (!processEntry)
@@ -472,8 +577,8 @@ export class ProcessManager {
472
577
  }
473
578
  removeProcess(processId) {
474
579
  const processEntry = this.processes.get(processId);
475
- if (processEntry?.cleanupTimer)
476
- clearTimeout(processEntry.cleanupTimer);
580
+ if (processEntry)
581
+ this.clearProcessTimers(processEntry);
477
582
  this.processes.delete(processId);
478
583
  const completedIndex = this.completedProcessIds.indexOf(processId);
479
584
  if (completedIndex >= 0)