hobots 1.3.2 → 1.3.4

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/dist/index.js CHANGED
@@ -58,7 +58,7 @@ function parseStacktrace(stack) {
58
58
 
59
59
  // src/utils.ts
60
60
  var SDK_NAME = "hobots";
61
- var SDK_VERSION = true ? "1.3.2" : "0.0.0-dev";
61
+ var SDK_VERSION = true ? "1.3.3" : "0.0.0-dev";
62
62
  function isNode() {
63
63
  return typeof process !== "undefined" && typeof process.versions?.node === "string";
64
64
  }
@@ -380,28 +380,61 @@ var Heartbeat = class {
380
380
  }
381
381
  };
382
382
 
383
+ // src/internal-handlers.ts
384
+ var INTERNAL = /* @__PURE__ */ Symbol.for("hobots.sdk.internalListener");
385
+ function markInternal(listener) {
386
+ listener[INTERNAL] = true;
387
+ return listener;
388
+ }
389
+ function externalListenerCount(event) {
390
+ const listeners = process.listeners(event);
391
+ return listeners.filter((listener) => !listener[INTERNAL]).length;
392
+ }
393
+ var emergencyTasks = [];
394
+ function addEmergencyTask(task) {
395
+ emergencyTasks.push(task);
396
+ }
397
+ async function awaitEmergencyTasks(capMs) {
398
+ if (emergencyTasks.length === 0) return;
399
+ await Promise.race([Promise.allSettled(emergencyTasks), sleep(capMs)]);
400
+ }
401
+
383
402
  // src/instrument.ts
403
+ var EMERGENCY_REPORT_CAP_MS = 3e3;
384
404
  var installed = false;
405
+ function hasInstalledGlobalHandlers() {
406
+ return installed;
407
+ }
385
408
  function installGlobalHandlers(client2, getScope) {
386
409
  if (installed) return;
387
410
  installed = true;
388
411
  if (isNode()) {
389
- process.on("uncaughtException", (error) => {
390
- client2.captureException(error, { level: "fatal", tags: { mechanism: "uncaughtException" } }, getScope());
391
- void client2.flush(2e3).then(() => {
392
- if (process.listenerCount("uncaughtException") <= 1) {
393
- console.error(error);
394
- process.exit(1);
395
- }
396
- });
397
- });
398
- process.on("unhandledRejection", (reason) => {
399
- client2.captureException(reason, { level: "error", tags: { mechanism: "unhandledRejection" } }, getScope());
400
- void client2.flush(2e3);
401
- });
402
- process.on("beforeExit", () => {
403
- void client2.flush(2e3);
404
- });
412
+ process.on(
413
+ "uncaughtException",
414
+ markInternal((error) => {
415
+ client2.captureException(error, { level: "fatal", tags: { mechanism: "uncaughtException" } }, getScope());
416
+ void client2.flush(2e3).then(async () => {
417
+ if (externalListenerCount("uncaughtException") === 0) {
418
+ await awaitEmergencyTasks(EMERGENCY_REPORT_CAP_MS);
419
+ console.error(error);
420
+ process.exit(1);
421
+ }
422
+ });
423
+ })
424
+ );
425
+ process.on(
426
+ "unhandledRejection",
427
+ markInternal((reason) => {
428
+ client2.captureException(reason, { level: "error", tags: { mechanism: "unhandledRejection" } }, getScope());
429
+ void client2.flush(2e3);
430
+ })
431
+ );
432
+ process.on(
433
+ "beforeExit",
434
+ markInternal(() => {
435
+ void client2.flush(2e3);
436
+ })
437
+ );
405
438
  return;
406
439
  }
407
440
  if (typeof window !== "undefined") {
@@ -697,25 +730,76 @@ var ManagerApi = class {
697
730
  await sleep(delay);
698
731
  }
699
732
  }
733
+ /**
734
+ * Um único PATCH com timeout curto — exit path (o processo está morrendo, o retry
735
+ * ladder do updateRun é proibido aqui). Status HTTP é ignorado: 409/404 significa
736
+ * que a run já foi cancelada/finalizada, o que é aceitável nesse caminho.
737
+ */
738
+ async updateRunOnce(runId, update, timeoutMs = 2e3) {
739
+ await fetch(`${this.baseUrl}/runs/${encodeURIComponent(runId)}`, {
740
+ method: "PATCH",
741
+ headers: this.headers(),
742
+ body: JSON.stringify(update),
743
+ signal: AbortSignal.timeout(timeoutMs)
744
+ });
745
+ }
700
746
  };
701
747
 
702
748
  // src/manager/context.ts
749
+ function runCanceledError() {
750
+ return Object.assign(new Error("execu\xE7\xE3o cancelada"), { name: "RunCanceled" });
751
+ }
752
+ function cancelableSleep(ms, signal) {
753
+ return new Promise((resolve, reject) => {
754
+ if (signal.aborted) {
755
+ reject(runCanceledError());
756
+ return;
757
+ }
758
+ const onAbort = () => {
759
+ clearTimeout(timer);
760
+ reject(runCanceledError());
761
+ };
762
+ const timer = setTimeout(() => {
763
+ signal.removeEventListener("abort", onAbort);
764
+ resolve();
765
+ }, ms);
766
+ signal.addEventListener("abort", onAbort, { once: true });
767
+ });
768
+ }
703
769
  var DEFAULT_LOG_FLUSH_INTERVAL_MS = 2e3;
770
+ var DEFAULT_CANCEL_CHECK_INTERVAL_MS = 5e3;
771
+ var MIN_CANCEL_CHECK_INTERVAL_MS = 1e3;
772
+ var MAX_CANCEL_CHECK_INTERVAL_MS = 15e3;
704
773
  var MAX_LOG_MESSAGE_CHARS = 1e4;
705
774
  var MAX_LOGS_PER_RUN = 500;
706
775
  var MAX_LOGS_PER_PATCH = 100;
707
776
  var FLUSH_AT_BUFFERED = 20;
708
777
  var RunReporter = class {
709
- constructor(api, runId, task, logger, flushIntervalMs) {
778
+ constructor(api, runId, task, logger, flushIntervalMs, cancelCheckIntervalMs) {
710
779
  this.api = api;
711
780
  this.runId = runId;
712
781
  this.logger = logger;
713
782
  this.flushIntervalMs = flushIntervalMs ?? DEFAULT_LOG_FLUSH_INTERVAL_MS;
783
+ this.cancelCheckIntervalMs = Math.min(
784
+ MAX_CANCEL_CHECK_INTERVAL_MS,
785
+ Math.max(MIN_CANCEL_CHECK_INTERVAL_MS, cancelCheckIntervalMs ?? DEFAULT_CANCEL_CHECK_INTERVAL_MS)
786
+ );
787
+ const log = {
788
+ debug: (message) => this.log("debug", message),
789
+ info: (message) => this.log("info", message),
790
+ warn: (message) => this.log("warning", message),
791
+ error: (message) => this.log("error", message)
792
+ };
793
+ const signal = this.abortController.signal;
714
794
  this.context = {
715
795
  runId,
716
796
  task,
717
- log: (level, message) => this.log(level, message),
718
- signal: this.abortController.signal
797
+ log,
798
+ signal,
799
+ throwIfCanceled: () => {
800
+ if (signal.aborted) throw runCanceledError();
801
+ },
802
+ sleep: (ms) => cancelableSleep(ms, signal)
719
803
  };
720
804
  }
721
805
  context;
@@ -724,16 +808,31 @@ var RunReporter = class {
724
808
  droppedCount = 0;
725
809
  dead = false;
726
810
  chain = Promise.resolve();
811
+ pendingUpdates = 0;
727
812
  timer;
813
+ watchTimer;
728
814
  abortController = new AbortController();
729
815
  flushIntervalMs;
816
+ cancelCheckIntervalMs;
730
817
  get isDead() {
731
818
  return this.dead;
732
819
  }
733
820
  /** Aborta o ctx.signal sem matar o reporter (timeout/shutdown ainda reportam o terminal). */
734
821
  abort() {
822
+ this.stopCancelWatch();
735
823
  if (!this.abortController.signal.aborted) this.abortController.abort();
736
824
  }
825
+ /**
826
+ * Mata o reporter de vez: aborta o signal e suprime todo reporte futuro.
827
+ * Usado no exit path — depois que o agente reportou AgentTerminated por fora
828
+ * da fila, um handler zumbi não pode reenviar status por cima.
829
+ */
830
+ kill() {
831
+ this.abort();
832
+ this.dead = true;
833
+ this.clearFlushTimer();
834
+ this.buffer = [];
835
+ }
737
836
  log(level, message) {
738
837
  if (this.dead) return;
739
838
  if (this.loggedCount >= MAX_LOGS_PER_RUN) {
@@ -788,22 +887,45 @@ var RunReporter = class {
788
887
  async reportRunning() {
789
888
  this.enqueue({ status: "running" });
790
889
  await this.chain;
890
+ if (!this.dead) this.startCancelWatch();
791
891
  return !this.dead;
792
892
  }
793
893
  reportSuccess() {
894
+ this.stopCancelWatch();
794
895
  this.clearFlushTimer();
795
896
  this.enqueue({ status: "succeeded", logs: this.takeLogs() });
796
897
  }
797
898
  reportFailure(error) {
899
+ this.stopCancelWatch();
798
900
  this.clearFlushTimer();
799
901
  this.enqueue({ status: "failed", error, logs: this.takeLogs() });
800
902
  }
903
+ /**
904
+ * Heartbeat de cancelamento: enquanto o handler roda, um PATCH 'running' vazio
905
+ * periódico faz o servidor responder 409 se a run foi cancelada no app — o que
906
+ * mata o reporter e dispara ctx.signal, mesmo em handlers que nunca logam.
907
+ */
908
+ startCancelWatch() {
909
+ if (this.watchTimer) return;
910
+ this.watchTimer = setInterval(() => {
911
+ if (this.dead || this.pendingUpdates > 0 || this.buffer.length > 0) return;
912
+ this.enqueue({ status: "running" });
913
+ }, this.cancelCheckIntervalMs);
914
+ unrefTimer(this.watchTimer);
915
+ }
916
+ stopCancelWatch() {
917
+ if (this.watchTimer) {
918
+ clearInterval(this.watchTimer);
919
+ this.watchTimer = void 0;
920
+ }
921
+ }
801
922
  /** Aguarda a fila de PATCHes esvaziar (chame depois do terminal). */
802
923
  finalize() {
803
924
  return this.chain;
804
925
  }
805
926
  enqueue(update) {
806
927
  if (this.dead) return;
928
+ this.pendingUpdates++;
807
929
  this.chain = this.chain.then(async () => {
808
930
  if (this.dead) return;
809
931
  const payload = update.logs && update.logs.length === 0 ? { ...update, logs: void 0 } : update;
@@ -812,6 +934,7 @@ var RunReporter = class {
812
934
  } catch (error) {
813
935
  if (error instanceof ManagerApiError && (error.status === 409 || error.status === 404)) {
814
936
  this.dead = true;
937
+ this.stopCancelWatch();
815
938
  this.abort();
816
939
  this.buffer = [];
817
940
  this.logger.warn(`run ${this.runId} cancelada/removida no servidor \u2014 reporte suprimido`);
@@ -819,10 +942,56 @@ var RunReporter = class {
819
942
  }
820
943
  this.logger.error(`update da run ${this.runId} perdido:`, error);
821
944
  }
945
+ }).finally(() => {
946
+ this.pendingUpdates--;
822
947
  });
823
948
  }
824
949
  };
825
950
 
951
+ // src/manager/shutdown.ts
952
+ var WATCHED_SIGNALS = ["SIGINT", "SIGTERM"];
953
+ function installShutdownHandlers(agent2) {
954
+ if (!isNode()) return () => {
955
+ };
956
+ let signaled = false;
957
+ const signalHandlers = /* @__PURE__ */ new Map();
958
+ for (const signal of WATCHED_SIGNALS) {
959
+ const handler = markInternal(() => {
960
+ if (externalListenerCount(signal) > 0) return;
961
+ if (signaled) return;
962
+ signaled = true;
963
+ const reraise = () => {
964
+ process.removeListener(signal, handler);
965
+ process.kill(process.pid, signal);
966
+ };
967
+ if (agent2.activeCount === 0) {
968
+ reraise();
969
+ return;
970
+ }
971
+ const report = agent2.reportAgentTerminated(`o processo do agente recebeu ${signal} durante a execu\xE7\xE3o`);
972
+ addEmergencyTask(report);
973
+ void report.finally(reraise);
974
+ });
975
+ process.on(signal, handler);
976
+ signalHandlers.set(signal, handler);
977
+ }
978
+ const onFatal = markInternal((error) => {
979
+ const report = agent2.activeCount > 0 ? agent2.reportAgentTerminated("o processo do agente morreu com um erro n\xE3o tratado durante a execu\xE7\xE3o") : Promise.resolve();
980
+ addEmergencyTask(report);
981
+ if (!hasInstalledGlobalHandlers() && externalListenerCount("uncaughtException") === 0) {
982
+ void report.finally(() => {
983
+ console.error(error);
984
+ process.exit(1);
985
+ });
986
+ }
987
+ });
988
+ process.on("uncaughtException", onFatal);
989
+ return () => {
990
+ for (const [signal, handler] of signalHandlers) process.removeListener(signal, handler);
991
+ process.removeListener("uncaughtException", onFatal);
992
+ };
993
+ }
994
+
826
995
  // src/manager/agent.ts
827
996
  var DEFAULT_POLL_INTERVAL_MS = 5e3;
828
997
  var MIN_POLL_INTERVAL_MS = 1e3;
@@ -845,6 +1014,7 @@ var ManagerAgent = class {
845
1014
  this.instanceId = options.instanceId;
846
1015
  this.taskTimeoutMs = options.taskTimeoutMs;
847
1016
  this.logFlushIntervalMs = options.logFlushIntervalMs;
1017
+ this.cancelCheckIntervalMs = options.cancelCheckIntervalMs;
848
1018
  this.logger.debug("agente configurado", { baseUrl: config.managerBaseUrl });
849
1019
  }
850
1020
  logger;
@@ -854,6 +1024,7 @@ var ManagerAgent = class {
854
1024
  instanceId;
855
1025
  taskTimeoutMs;
856
1026
  logFlushIntervalMs;
1027
+ cancelCheckIntervalMs;
857
1028
  state = "idle";
858
1029
  timer;
859
1030
  pollAbort;
@@ -861,6 +1032,7 @@ var ManagerAgent = class {
861
1032
  consecutiveAuthFailures = 0;
862
1033
  activeRuns = /* @__PURE__ */ new Set();
863
1034
  reporters = /* @__PURE__ */ new Set();
1035
+ uninstallShutdown;
864
1036
  get activeCount() {
865
1037
  return this.activeRuns.size;
866
1038
  }
@@ -873,6 +1045,9 @@ var ManagerAgent = class {
873
1045
  throw new Error("[hobots-manager] nenhuma task registrada \u2014 chame register() antes de start()");
874
1046
  }
875
1047
  this.state = "started";
1048
+ if (this.options.installExitHandlers !== false && !this.uninstallShutdown) {
1049
+ this.uninstallShutdown = installShutdownHandlers(this);
1050
+ }
876
1051
  this.logger.debug(`polling iniciado (tasks: ${[...this.registry.keys()].join(", ")})`);
877
1052
  this.schedule(0);
878
1053
  }
@@ -889,11 +1064,32 @@ var ManagerAgent = class {
889
1064
  const finished = timeoutMs > 0 ? await Promise.race([all, sleep(timeoutMs).then(() => false)]) : false;
890
1065
  if (!finished && this.activeRuns.size > 0) {
891
1066
  this.logger.warn(`${this.activeRuns.size} run(s) ainda em execu\xE7\xE3o ap\xF3s ${timeoutMs}ms \u2014 abandonando a espera`);
892
- for (const reporter of this.reporters) reporter.abort();
1067
+ await this.reportAgentTerminated("agente encerrado durante a execu\xE7\xE3o (shutdown excedeu o tempo de espera)");
893
1068
  }
1069
+ this.uninstallShutdown?.();
1070
+ this.uninstallShutdown = void 0;
894
1071
  this.state = "idle";
895
1072
  return finished;
896
1073
  }
1074
+ /**
1075
+ * Best-effort do exit path: reporta 'failed'/AgentTerminated para as runs ativas
1076
+ * antes do processo morrer, fora da fila do reporter e com timeout curto. Runs já
1077
+ * canceladas no app (reporter morto) são puladas; erros de rede são engolidos —
1078
+ * o sweep server-side é o backstop.
1079
+ */
1080
+ async reportAgentTerminated(message, timeoutMs = 2500) {
1081
+ const pending = [];
1082
+ for (const reporter of this.reporters) {
1083
+ if (reporter.isDead) continue;
1084
+ reporter.kill();
1085
+ pending.push(
1086
+ this.api.updateRunOnce(reporter.context.runId, { status: "failed", error: { type: "AgentTerminated", message } }, timeoutMs).catch(() => {
1087
+ })
1088
+ );
1089
+ }
1090
+ if (pending.length === 0) return;
1091
+ await Promise.race([Promise.allSettled(pending), sleep(timeoutMs)]);
1092
+ }
897
1093
  /** Timer intencionalmente NÃO unref'd: o agente é a razão do processo existir. */
898
1094
  schedule(delayMs) {
899
1095
  if (this.state !== "started" || this.timer) return;
@@ -960,7 +1156,7 @@ var ManagerAgent = class {
960
1156
  this.activeRuns.add(promise);
961
1157
  }
962
1158
  async executeRun(run) {
963
- const reporter = new RunReporter(this.api, run.id, run.task, this.logger, this.logFlushIntervalMs);
1159
+ const reporter = new RunReporter(this.api, run.id, run.task, this.logger, this.logFlushIntervalMs, this.cancelCheckIntervalMs);
964
1160
  this.reporters.add(reporter);
965
1161
  try {
966
1162
  const handler = this.registry.get(run.task);