@danhachuel/thunderbolt 0.3.82 → 0.3.84

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/app/main.py CHANGED
@@ -3505,7 +3505,7 @@ def _render_pipeline_worker_banner(worker_status: dict[str, Any], active_count:
3505
3505
  st.warning("Worker de vídeo sem heartbeat recente. O launcher deve estar aberto para processar as tarefas.")
3506
3506
  heartbeat = worker_status.get("last_heartbeat_at") or worker_status.get("updated_at")
3507
3507
  if heartbeat:
3508
- st.caption(f"{_pipeline_time_age(heartbeat)} · timeout de execução: {STALE_TASK_SECONDS // 60} minutos")
3508
+ st.caption(f"{_pipeline_time_age(heartbeat)} · vídeo: até 90 min com watchdog de inactividade de 10 min")
3509
3509
  if worker_status.get("last_error"):
3510
3510
  st.error(f"Último erro do worker: {worker_status['last_error']}")
3511
3511
 
@@ -4,6 +4,7 @@ import json
4
4
  import os
5
5
  import queue
6
6
  import re
7
+ import signal
7
8
  import subprocess
8
9
  import threading
9
10
  import time
@@ -27,6 +28,8 @@ from hermes_ui.thumbnail_generation import ThumbnailGenerationError, generate_th
27
28
  PIPELINE_LOCK_FILENAME = "pipeline_worker.lock"
28
29
  PIPELINE_LOG_FILENAME = "pipeline_worker.json"
29
30
  VIDEO_TIMEOUT_SECONDS = 20 * 60
31
+ LONG_STOCK_VIDEO_TIMEOUT_SECONDS = 90 * 60
32
+ VIDEO_IDLE_TIMEOUT_SECONDS = 10 * 60
30
33
  STALE_TASK_SECONDS = VIDEO_TIMEOUT_SECONDS + 5 * 60
31
34
  WORKER_HEARTBEAT_TIMEOUT_SECONDS = 15
32
35
  CASCADE_STAGE_ORDER = ("topic", "script", "title", "keywords", "video", "thumbnail_prompt", "thumbnail", "upload")
@@ -184,13 +187,14 @@ def _recover_stale_tasks() -> list[str]:
184
187
  if not updated_at:
185
188
  continue
186
189
  age_seconds = (current_time - updated_at.astimezone(timezone.utc)).total_seconds()
187
- if age_seconds <= STALE_TASK_SECONDS:
190
+ timeout_seconds = _task_stale_timeout_seconds(task)
191
+ if age_seconds <= timeout_seconds:
188
192
  continue
189
193
  task_id = str(task.get("id") or "")
190
194
  if not task_id:
191
195
  continue
192
196
  message = (
193
- f"A tarefa ficou sem heartbeat durante mais de {STALE_TASK_SECONDS // 60} minutos. "
197
+ f"A tarefa ficou sem heartbeat durante mais de {timeout_seconds // 60} minutos. "
194
198
  "Foi marcada como falhada para evitar execução eterna; reveja o log do worker."
195
199
  )
196
200
  failed_stage = str(task.get("stage") or "pipeline")
@@ -568,7 +572,21 @@ def _persist_video_diagnostics(task: dict[str, Any], output: str) -> dict[str, s
568
572
 
569
573
  def _stop_process(process: subprocess.Popen[str]) -> None:
570
574
  if process.poll() is None:
571
- process.kill()
575
+ try:
576
+ process_id = getattr(process, "pid", None)
577
+ if os.name != "nt" and process_id:
578
+ os.killpg(os.getpgid(process_id), signal.SIGKILL)
579
+ elif os.name == "nt" and process_id:
580
+ subprocess.run(
581
+ ["taskkill", "/PID", str(process_id), "/T", "/F"],
582
+ check=False,
583
+ stdout=subprocess.DEVNULL,
584
+ stderr=subprocess.DEVNULL,
585
+ )
586
+ else:
587
+ process.kill()
588
+ except (OSError, ProcessLookupError):
589
+ process.kill()
572
590
  try:
573
591
  process.wait(timeout=5)
574
592
  except subprocess.TimeoutExpired:
@@ -602,6 +620,34 @@ def _normalise_video_route(task: dict[str, Any], settings: dict[str, Any]) -> st
602
620
  return raw if raw in {"pexels", "pixabay", "local"} else "pexels"
603
621
 
604
622
 
623
+ def _video_timeout_seconds(task: dict[str, Any], settings: dict[str, Any] | None = None) -> int:
624
+ """Reserve extra bounded time only for long stock-video downloads and assembly."""
625
+ effective_settings = settings if isinstance(settings, dict) else _settings()
626
+ route = _normalise_video_route(task, effective_settings)
627
+ script = str(task.get("video_script") or "").strip()
628
+ if route in {"pexels", "pixabay"} and len(script) >= 1_200:
629
+ return max(VIDEO_TIMEOUT_SECONDS, LONG_STOCK_VIDEO_TIMEOUT_SECONDS)
630
+ return VIDEO_TIMEOUT_SECONDS
631
+
632
+
633
+ def _task_stale_timeout_seconds(task: dict[str, Any]) -> int:
634
+ """Keep stale-task recovery aligned with the actual execution budget."""
635
+ if str(task.get("stage") or "").strip().casefold() == "video":
636
+ return _video_timeout_seconds(task) + 5 * 60
637
+ return STALE_TASK_SECONDS
638
+
639
+
640
+ def _latest_helper_log_activity(log_path: Path | None, previous_mtime: float) -> tuple[float, bool]:
641
+ """Return whether the helper's file log advanced without reading its content."""
642
+ if log_path is None:
643
+ return previous_mtime, False
644
+ try:
645
+ current_mtime = log_path.stat().st_mtime
646
+ except OSError:
647
+ return previous_mtime, False
648
+ return (current_mtime, current_mtime > previous_mtime)
649
+
650
+
605
651
  def _mpt_aspect_ratio(value: Any, format_value: Any = "") -> str:
606
652
  raw = str(value or format_value or "").strip().casefold()
607
653
  if raw in {"landscape 16:9", "wide", "16:9", "landscape"}:
@@ -847,6 +893,7 @@ def _run_video_helper(task: dict[str, Any]) -> Path:
847
893
  output_lines: list[str] = []
848
894
  line_queue: queue.Queue[str | None] = queue.Queue()
849
895
  started_at = time.monotonic()
896
+ timeout_seconds = _video_timeout_seconds(task, settings)
850
897
  process: subprocess.Popen[str] | None = None
851
898
 
852
899
  def _read_output() -> None:
@@ -863,6 +910,7 @@ def _run_video_helper(task: dict[str, Any]) -> Path:
863
910
  command,
864
911
  cwd=helper_dir,
865
912
  env=env,
913
+ start_new_session=os.name != "nt",
866
914
  stdout=subprocess.PIPE,
867
915
  stderr=subprocess.STDOUT,
868
916
  text=True,
@@ -879,6 +927,9 @@ def _run_video_helper(task: dict[str, Any]) -> Path:
879
927
  output_finished = False
880
928
  last_heartbeat = 0.0
881
929
  last_output_line = ""
930
+ last_activity_at = started_at
931
+ helper_log_path: Path | None = None
932
+ helper_log_mtime = 0.0
882
933
  try:
883
934
  while True:
884
935
  try:
@@ -888,9 +939,19 @@ def _run_video_helper(task: dict[str, Any]) -> Path:
888
939
  elif line:
889
940
  last_output_line = _redact_helper_output(line).strip()
890
941
  output_lines.append(line)
942
+ last_activity_at = time.monotonic()
943
+ match = re.search(r"full generation log:\s*(.+)$", last_output_line, flags=re.IGNORECASE)
944
+ if match:
945
+ candidate = Path(match.group(1).strip()).expanduser()
946
+ helper_log_path = candidate if candidate.is_file() else None
891
947
  except queue.Empty:
892
948
  pass
893
949
  elapsed = time.monotonic() - started_at
950
+ helper_log_mtime, helper_log_advanced = _latest_helper_log_activity(helper_log_path, helper_log_mtime)
951
+ if helper_log_advanced:
952
+ last_activity_at = time.monotonic()
953
+ if not last_output_line:
954
+ last_output_line = "[MoneyPrinterTurbo] actividade de geração confirmada"
894
955
  if elapsed - last_heartbeat >= 5:
895
956
  # O helper expõe o resultado final, mas não uma percentagem estável.
896
957
  # Mantemos uma faixa reservada para a etapa de vídeo e avançamos-a
@@ -917,9 +978,17 @@ def _run_video_helper(task: dict[str, Any]) -> Path:
917
978
  last_heartbeat = elapsed
918
979
  if process.poll() is not None and output_finished:
919
980
  break
920
- if elapsed >= VIDEO_TIMEOUT_SECONDS:
981
+ if elapsed >= timeout_seconds:
921
982
  _stop_process(process)
922
- message = f"A etapa Vídeo excedeu o limite de {VIDEO_TIMEOUT_SECONDS // 60} minutos e foi encerrada."
983
+ message = f"A etapa Vídeo excedeu o limite de {timeout_seconds // 60} minutos e foi encerrada."
984
+ metadata = _failure_attribution(task, settings, "video", error=message)
985
+ raise PipelineError(_failure_message(message, metadata), failure_metadata=metadata)
986
+ if time.monotonic() - last_activity_at >= VIDEO_IDLE_TIMEOUT_SECONDS:
987
+ _stop_process(process)
988
+ message = (
989
+ "A etapa Vídeo não apresentou actividade comprovada do motor durante "
990
+ f"{VIDEO_IDLE_TIMEOUT_SECONDS // 60} minutos e foi encerrada."
991
+ )
923
992
  metadata = _failure_attribution(task, settings, "video", error=message)
924
993
  raise PipelineError(_failure_message(message, metadata), failure_metadata=metadata)
925
994
  finally:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.3.82",
3
+ "version": "0.3.84",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "license": "MIT",
6
6
  "main": "scripts/cli.mjs",