@sema-agent/core 1.317.0 → 1.319.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.
@@ -1,6 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
- import { closeSync, constants, createReadStream, mkdtempSync, openSync, readSync, statSync } from "node:fs";
3
+ import { closeSync, constants, createReadStream, mkdtempSync, openSync, readSync, statSync, truncateSync, unlinkSync } from "node:fs";
4
4
  import { access, appendFile, lstat, mkdir, mkdtemp, readdir, readFile, readlink, realpath, rm, writeFile, } from "node:fs/promises";
5
5
  import { tmpdir } from "node:os";
6
6
  import { isAbsolute, join, resolve } from "node:path";
@@ -203,14 +203,19 @@ function getShellEnv(inheritEnv, baseEnv, extraEnv) {
203
203
  ...extraEnv,
204
204
  };
205
205
  }
206
+ const EXIT_SETTLE_DRAIN_MS = 500;
207
+ const EXEC_SPOOL_PUMP_MS = 200;
208
+ const EXEC_SPOOL_ROTATE_BYTES = 8 * 1024 * 1024;
209
+ const EXEC_SPOOL_FINAL_TAIL_BYTES = 8 * 1024 * 1024;
210
+ const EXEC_SPOOL_HARD_CAP_BYTES = 16 * 1024 * 1024;
206
211
  const SIGNUM = { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGABRT: 6, SIGKILL: 9, SIGSEGV: 11, SIGPIPE: 13, SIGTERM: 15 };
207
212
  export function openSpoolPair(base) {
208
213
  const outPath = `${base}.out.log`;
209
214
  const errPath = `${base}.err.log`;
210
- const fdOut = openSync(outPath, "wx", 0o600);
215
+ const fdOut = openSync(outPath, "ax", 0o600);
211
216
  let fdErr;
212
217
  try {
213
- fdErr = openSync(errPath, "wx", 0o600);
218
+ fdErr = openSync(errPath, "ax", 0o600);
214
219
  }
215
220
  catch (e) {
216
221
  try {
@@ -297,6 +302,10 @@ export class NodeExecutionEnv {
297
302
  let child;
298
303
  const timeoutRef = {};
299
304
  const forceSettleRef = {};
305
+ const spoolPumpRef = {};
306
+ const execSpoolRef = {};
307
+ const pumpSpoolRef = {};
308
+ const drainSpoolRef = {};
300
309
  const armForceSettle = (result) => {
301
310
  if (forceSettleRef.current || settled)
302
311
  return;
@@ -312,6 +321,7 @@ export class NodeExecutionEnv {
312
321
  forceSettleRef.current.unref?.();
313
322
  };
314
323
  const partialOutput = () => {
324
+ drainSpoolRef.current?.();
315
325
  const so = stdoutTail.result();
316
326
  const se = stderrTail.result();
317
327
  return { stdout: markTruncated(so.text, so.droppedBytes), stderr: markTruncated(se.text, se.droppedBytes) };
@@ -343,22 +353,62 @@ export class NodeExecutionEnv {
343
353
  return;
344
354
  }
345
355
  settled = true;
356
+ if (spoolPumpRef.current)
357
+ clearInterval(spoolPumpRef.current);
358
+ if (execSpoolRef.current && !(result.ok && result.value.detached !== undefined)) {
359
+ for (const path of [execSpoolRef.current.outPath, execSpoolRef.current.errPath]) {
360
+ try {
361
+ unlinkSync(path);
362
+ }
363
+ catch {
364
+ }
365
+ }
366
+ execSpoolRef.current = undefined;
367
+ }
346
368
  resolvePromise(result);
347
369
  };
370
+ let execSpoolFds;
371
+ if (process.platform !== "win32") {
372
+ try {
373
+ if (!this.bgSpoolDir)
374
+ this.bgSpoolDir = mkdtempSync(join(tmpdir(), "sema-bg-"));
375
+ const pair = openSpoolPair(join(this.bgSpoolDir, `fg_${randomUUID()}`));
376
+ execSpoolRef.current = { outPath: pair.outPath, errPath: pair.errPath };
377
+ execSpoolFds = pair.fds;
378
+ }
379
+ catch {
380
+ execSpoolRef.current = undefined;
381
+ execSpoolFds = undefined;
382
+ }
383
+ }
384
+ const closeSpoolFds = () => {
385
+ if (!execSpoolFds)
386
+ return;
387
+ for (const fd of execSpoolFds) {
388
+ try {
389
+ closeSync(fd);
390
+ }
391
+ catch {
392
+ }
393
+ }
394
+ execSpoolFds = undefined;
395
+ };
348
396
  try {
349
397
  child = spawn(shellConfig.value.shell, [...shellConfig.value.args, command], {
350
398
  cwd,
351
399
  detached: process.platform !== "win32",
352
400
  env: getShellEnv(this.inheritEnv, this.shellEnv, options?.env),
353
- stdio: ["ignore", "pipe", "pipe"],
401
+ stdio: execSpoolFds ? ["ignore", execSpoolFds[0], execSpoolFds[1]] : ["ignore", "pipe", "pipe"],
354
402
  windowsHide: true,
355
403
  });
356
404
  }
357
405
  catch (error) {
406
+ closeSpoolFds();
358
407
  const cause = toError(error);
359
408
  settle(err(new ExecutionError("spawn_error", cause.message, cause)));
360
409
  return;
361
410
  }
411
+ closeSpoolFds();
362
412
  if (options?.onSpawn && child.pid !== undefined) {
363
413
  const spawnedPid = child.pid;
364
414
  const spawnedChild = child;
@@ -392,12 +442,116 @@ export class NodeExecutionEnv {
392
442
  options.abortSignal.addEventListener("abort", onAbort, { once: true });
393
443
  }
394
444
  }
445
+ const spoolCursor = { out: 0, err: 0 };
446
+ const readSpoolIncrement = (path, cursor) => {
447
+ try {
448
+ const size = statSync(path).size;
449
+ if (size <= spoolCursor[cursor])
450
+ return undefined;
451
+ const fd = openSync(path, "r");
452
+ try {
453
+ const want = Math.min(size - spoolCursor[cursor], 8 * 1024 * 1024 + 4096);
454
+ const buf = Buffer.alloc(want);
455
+ const got = readSync(fd, buf, 0, want, spoolCursor[cursor]);
456
+ if (got <= 0)
457
+ return undefined;
458
+ spoolCursor[cursor] += got;
459
+ return buf.subarray(0, got);
460
+ }
461
+ finally {
462
+ closeSync(fd);
463
+ }
464
+ }
465
+ catch {
466
+ return undefined;
467
+ }
468
+ };
469
+ const feed = (tail, cb, buf) => {
470
+ tail.push(buf);
471
+ if (cb === undefined || settled)
472
+ return;
473
+ try {
474
+ cb(buf.toString("utf8"));
475
+ }
476
+ catch (error) {
477
+ const cause = toError(error);
478
+ callbackError = new ExecutionError("callback_error", cause.message, cause);
479
+ onAbort();
480
+ }
481
+ };
482
+ const spoolLanes = () => {
483
+ const sp = execSpoolRef.current;
484
+ if (sp === undefined)
485
+ return [];
486
+ return [
487
+ { path: sp.outPath, cursor: "out", tail: stdoutTail, cb: options?.onStdout },
488
+ { path: sp.errPath, cursor: "err", tail: stderrTail, cb: options?.onStderr },
489
+ ];
490
+ };
491
+ const skipAhead = (lane, keep) => {
492
+ try {
493
+ const behind = statSync(lane.path).size - spoolCursor[lane.cursor];
494
+ if (behind <= keep)
495
+ return;
496
+ lane.tail.recordSkippedBytes(behind - keep);
497
+ spoolCursor[lane.cursor] += behind - keep;
498
+ }
499
+ catch {
500
+ }
501
+ };
502
+ const reclaimSpool = (lane) => {
503
+ if (spoolCursor[lane.cursor] < EXEC_SPOOL_ROTATE_BYTES)
504
+ return;
505
+ try {
506
+ const size = statSync(lane.path).size;
507
+ if (size === spoolCursor[lane.cursor]) {
508
+ truncateSync(lane.path, 0);
509
+ spoolCursor[lane.cursor] = 0;
510
+ }
511
+ else if (size >= EXEC_SPOOL_HARD_CAP_BYTES) {
512
+ lane.tail.recordSkippedBytes(size - spoolCursor[lane.cursor]);
513
+ truncateSync(lane.path, 0);
514
+ spoolCursor[lane.cursor] = 0;
515
+ }
516
+ }
517
+ catch {
518
+ }
519
+ };
520
+ const pumpSpool = () => {
521
+ for (const lane of spoolLanes()) {
522
+ skipAhead(lane, EXEC_SPOOL_ROTATE_BYTES);
523
+ const inc = readSpoolIncrement(lane.path, lane.cursor);
524
+ if (inc)
525
+ feed(lane.tail, lane.cb, inc);
526
+ reclaimSpool(lane);
527
+ }
528
+ };
529
+ pumpSpoolRef.current = pumpSpool;
530
+ const drainSpoolFinal = () => {
531
+ for (const lane of spoolLanes()) {
532
+ skipAhead(lane, EXEC_SPOOL_FINAL_TAIL_BYTES);
533
+ for (let i = 0; i < 4; i++) {
534
+ const inc = readSpoolIncrement(lane.path, lane.cursor);
535
+ if (!inc)
536
+ break;
537
+ feed(lane.tail, lane.cb, inc);
538
+ }
539
+ }
540
+ };
541
+ drainSpoolRef.current = drainSpoolFinal;
542
+ if (execSpoolRef.current) {
543
+ spoolPumpRef.current = setInterval(pumpSpool, EXEC_SPOOL_PUMP_MS);
544
+ spoolPumpRef.current.unref?.();
545
+ }
395
546
  const onDetach = () => {
396
547
  if (settled || !child)
397
548
  return;
398
- const shellId = this.adoptRunningChild(child, stdoutTail.result().text, stderrTail.result().text);
549
+ pumpSpoolRef.current?.();
550
+ const shellId = this.adoptRunningChild(child, stdoutTail.result(), stderrTail.result(), execSpoolRef.current !== undefined ? { spool: execSpoolRef.current, cursors: { out: spoolCursor.out, err: spoolCursor.err } } : undefined);
399
551
  if (shellId === undefined)
400
552
  return;
553
+ if (spoolPumpRef.current)
554
+ clearInterval(spoolPumpRef.current);
401
555
  try {
402
556
  options?.onDetachAdopted?.();
403
557
  }
@@ -405,7 +559,9 @@ export class NodeExecutionEnv {
405
559
  }
406
560
  if (timeoutRef.current)
407
561
  clearTimeout(timeoutRef.current);
408
- settle(ok({ stdout: stdoutTail.result().text, stderr: stderrTail.result().text, exitCode: 0, detached: { shellId } }));
562
+ const dso = stdoutTail.result();
563
+ const dse = stderrTail.result();
564
+ settle(ok({ stdout: markTruncated(dso.text, dso.droppedBytes), stderr: markTruncated(dse.text, dse.droppedBytes), exitCode: 0, detached: { shellId } }));
409
565
  };
410
566
  if (options?.detachSignal) {
411
567
  if (options.detachSignal.aborted)
@@ -415,30 +571,32 @@ export class NodeExecutionEnv {
415
571
  options.detachSignal.addEventListener("abort", dl, { once: true });
416
572
  }
417
573
  }
418
- child.stdout?.setEncoding("utf8");
419
- child.stderr?.setEncoding("utf8");
420
- child.stdout?.on("data", (chunk) => {
421
- stdoutTail.push(Buffer.from(chunk, "utf8"));
422
- try {
423
- options?.onStdout?.(chunk);
424
- }
425
- catch (error) {
426
- const cause = toError(error);
427
- callbackError = new ExecutionError("callback_error", cause.message, cause);
428
- onAbort();
429
- }
430
- });
431
- child.stderr?.on("data", (chunk) => {
432
- stderrTail.push(Buffer.from(chunk, "utf8"));
433
- try {
434
- options?.onStderr?.(chunk);
435
- }
436
- catch (error) {
437
- const cause = toError(error);
438
- callbackError = new ExecutionError("callback_error", cause.message, cause);
439
- onAbort();
440
- }
441
- });
574
+ if (!execSpoolRef.current) {
575
+ child.stdout?.setEncoding("utf8");
576
+ child.stderr?.setEncoding("utf8");
577
+ child.stdout?.on("data", (chunk) => {
578
+ stdoutTail.push(Buffer.from(chunk, "utf8"));
579
+ try {
580
+ options?.onStdout?.(chunk);
581
+ }
582
+ catch (error) {
583
+ const cause = toError(error);
584
+ callbackError = new ExecutionError("callback_error", cause.message, cause);
585
+ onAbort();
586
+ }
587
+ });
588
+ child.stderr?.on("data", (chunk) => {
589
+ stderrTail.push(Buffer.from(chunk, "utf8"));
590
+ try {
591
+ options?.onStderr?.(chunk);
592
+ }
593
+ catch (error) {
594
+ const cause = toError(error);
595
+ callbackError = new ExecutionError("callback_error", cause.message, cause);
596
+ onAbort();
597
+ }
598
+ });
599
+ }
442
600
  child.stdout?.on("error", () => { });
443
601
  child.stderr?.on("error", () => { });
444
602
  child.on("error", (error) => {
@@ -448,7 +606,8 @@ export class NodeExecutionEnv {
448
606
  }
449
607
  settle(err(new ExecutionError("spawn_error", error.message, error)));
450
608
  });
451
- child.on("close", (code, signal) => {
609
+ const settleFromTermination = (code, signal) => {
610
+ drainSpoolFinal();
452
611
  if (callbackError) {
453
612
  const p = partialOutput();
454
613
  callbackError.partialStdout = p.stdout;
@@ -476,6 +635,16 @@ export class NodeExecutionEnv {
476
635
  return;
477
636
  }
478
637
  settle(ok({ stdout: markTruncated(so.text, so.droppedBytes), stderr: markTruncated(se.text, se.droppedBytes), exitCode: code ?? 0 }));
638
+ };
639
+ child.on("close", (code, signal) => settleFromTermination(code, signal));
640
+ child.on("exit", (code, signal) => {
641
+ if (execSpoolRef.current === undefined)
642
+ return;
643
+ const drain = setTimeout(() => {
644
+ settleFromTermination(code, signal);
645
+ }, EXIT_SETTLE_DRAIN_MS);
646
+ drain.unref?.();
647
+ child.once("close", () => clearTimeout(drain));
479
648
  });
480
649
  });
481
650
  }
@@ -714,7 +883,7 @@ export class NodeExecutionEnv {
714
883
  return err(toFileError(error, filePath));
715
884
  }
716
885
  }
717
- adoptRunningChild(child, stdoutSoFar, stderrSoFar) {
886
+ adoptRunningChild(child, stdoutSoFar, stderrSoFar, handover) {
718
887
  const liveCount = [...this.bgShells.values()].filter((e) => e.status === "running").length;
719
888
  if (liveCount >= this.backgroundCapabilities.maxConcurrent)
720
889
  return undefined;
@@ -724,13 +893,18 @@ export class NodeExecutionEnv {
724
893
  stdout: { tail: new RollingTailBuffer(), totalBytes: 0, cursorBytes: 0 },
725
894
  stderr: { tail: new RollingTailBuffer(), totalBytes: 0, cursorBytes: 0 },
726
895
  status: "running",
896
+ ...(handover !== undefined
897
+ ? { spool: { outPath: handover.spool.outPath, errPath: handover.spool.errPath, fileCursor: { ...handover.cursors }, ephemeral: true } }
898
+ : {}),
727
899
  };
728
- const seedOut = Buffer.from(stdoutSoFar, "utf8");
729
- const seedErr = Buffer.from(stderrSoFar, "utf8");
900
+ const seedOut = Buffer.from(stdoutSoFar.text, "utf8");
901
+ const seedErr = Buffer.from(stderrSoFar.text, "utf8");
902
+ entry.stdout.tail.recordSkippedBytes(stdoutSoFar.droppedBytes);
903
+ entry.stderr.tail.recordSkippedBytes(stderrSoFar.droppedBytes);
730
904
  entry.stdout.tail.push(seedOut);
731
- entry.stdout.totalBytes += seedOut.length;
732
905
  entry.stderr.tail.push(seedErr);
733
- entry.stderr.totalBytes += seedErr.length;
906
+ entry.stdout.totalBytes += stdoutSoFar.droppedBytes + seedOut.length;
907
+ entry.stderr.totalBytes += stderrSoFar.droppedBytes + seedErr.length;
734
908
  child.stdout?.on("data", (chunk) => {
735
909
  const buf = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
736
910
  entry.stdout.tail.push(buf);
@@ -750,6 +924,7 @@ export class NodeExecutionEnv {
750
924
  child.on("close", (code, signal) => {
751
925
  if (entry.timer)
752
926
  clearTimeout(entry.timer);
927
+ this.reclaimAdoptedSpool(entry);
753
928
  if (entry.status === "killed")
754
929
  return;
755
930
  entry.status = "exited";
@@ -758,6 +933,7 @@ export class NodeExecutionEnv {
758
933
  if (child.exitCode !== null || child.signalCode !== null) {
759
934
  entry.status = "exited";
760
935
  entry.exitCode = child.exitCode ?? (child.signalCode ? 128 + (SIGNUM[child.signalCode] ?? 9) : 0);
936
+ this.reclaimAdoptedSpool(entry);
761
937
  }
762
938
  else {
763
939
  const timeoutSec = this.backgroundCapabilities.defaultBgTimeoutSec;
@@ -789,7 +965,7 @@ export class NodeExecutionEnv {
789
965
  if (!this.bgSpoolDir)
790
966
  this.bgSpoolDir = mkdtempSync(join(tmpdir(), "sema-bg-"));
791
967
  const pair = openSpoolPair(join(this.bgSpoolDir, `bg_${this.bgCounter + 1}_${randomUUID()}`));
792
- spool = { outPath: pair.outPath, errPath: pair.errPath };
968
+ spool = { outPath: pair.outPath, errPath: pair.errPath, fileCursor: { out: 0, err: 0 } };
793
969
  spoolFds = pair.fds;
794
970
  }
795
971
  catch {
@@ -922,35 +1098,74 @@ export class NodeExecutionEnv {
922
1098
  return ok(undefined);
923
1099
  }
924
1100
  syncSpool(entry) {
925
- if (!entry.spool)
1101
+ const spool = entry.spool;
1102
+ if (!spool)
926
1103
  return;
927
1104
  const streams = [
928
- [entry.stdout, entry.spool.outPath],
929
- [entry.stderr, entry.spool.errPath],
1105
+ [entry.stdout, spool.outPath, "out"],
1106
+ [entry.stderr, spool.errPath, "err"],
930
1107
  ];
931
- for (const [stream, path] of streams) {
1108
+ for (const [stream, path, lane] of streams) {
932
1109
  try {
933
1110
  const size = statSync(path).size;
934
- if (size <= stream.totalBytes)
935
- continue;
936
- const fd = openSync(path, "r");
937
- try {
938
- const want = Math.min(size - stream.totalBytes, 8 * 1024 * 1024 + 4096);
939
- const buf = Buffer.alloc(want);
940
- const got = readSync(fd, buf, 0, want, stream.totalBytes);
941
- if (got > 0) {
942
- stream.tail.push(buf.subarray(0, got));
943
- stream.totalBytes += got;
1111
+ if (spool.ephemeral) {
1112
+ const behind = size - spool.fileCursor[lane];
1113
+ if (behind > EXEC_SPOOL_ROTATE_BYTES) {
1114
+ const skipped = behind - EXEC_SPOOL_ROTATE_BYTES;
1115
+ stream.tail.recordSkippedBytes(skipped);
1116
+ stream.totalBytes += skipped;
1117
+ spool.fileCursor[lane] += skipped;
944
1118
  }
945
1119
  }
946
- finally {
947
- closeSync(fd);
1120
+ if (size > spool.fileCursor[lane]) {
1121
+ const fd = openSync(path, "r");
1122
+ try {
1123
+ const want = Math.min(size - spool.fileCursor[lane], 8 * 1024 * 1024 + 4096);
1124
+ const buf = Buffer.alloc(want);
1125
+ const got = readSync(fd, buf, 0, want, spool.fileCursor[lane]);
1126
+ if (got > 0) {
1127
+ stream.tail.push(buf.subarray(0, got));
1128
+ stream.totalBytes += got;
1129
+ spool.fileCursor[lane] += got;
1130
+ }
1131
+ }
1132
+ finally {
1133
+ closeSync(fd);
1134
+ }
1135
+ }
1136
+ if (spool.ephemeral && spool.fileCursor[lane] >= EXEC_SPOOL_ROTATE_BYTES) {
1137
+ const size2 = statSync(path).size;
1138
+ if (size2 === spool.fileCursor[lane]) {
1139
+ truncateSync(path, 0);
1140
+ spool.fileCursor[lane] = 0;
1141
+ }
1142
+ else if (size2 >= EXEC_SPOOL_HARD_CAP_BYTES) {
1143
+ const skipped = size2 - spool.fileCursor[lane];
1144
+ stream.tail.recordSkippedBytes(skipped);
1145
+ stream.totalBytes += skipped;
1146
+ truncateSync(path, 0);
1147
+ spool.fileCursor[lane] = 0;
1148
+ }
948
1149
  }
949
1150
  }
950
1151
  catch {
951
1152
  }
952
1153
  }
953
1154
  }
1155
+ reclaimAdoptedSpool(entry) {
1156
+ const spool = entry.spool;
1157
+ if (!spool?.ephemeral)
1158
+ return;
1159
+ this.syncSpool(entry);
1160
+ entry.spool = undefined;
1161
+ for (const path of [spool.outPath, spool.errPath]) {
1162
+ try {
1163
+ unlinkSync(path);
1164
+ }
1165
+ catch {
1166
+ }
1167
+ }
1168
+ }
954
1169
  async disposeBackgroundShells(opts) {
955
1170
  if (this.retainBackgroundProcesses)
956
1171
  return;
@@ -965,6 +1180,7 @@ export class NodeExecutionEnv {
965
1180
  entry.status = "killed";
966
1181
  killProcessTree(entry.child.pid, { force: true });
967
1182
  }
1183
+ this.reclaimAdoptedSpool(entry);
968
1184
  }
969
1185
  catch {
970
1186
  }