@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 +1 -1
- package/hermes_ui/pipeline_worker.py +74 -5
- package/package.json +1 -1
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)} ·
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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 >=
|
|
981
|
+
if elapsed >= timeout_seconds:
|
|
921
982
|
_stop_process(process)
|
|
922
|
-
message = f"A etapa Vídeo excedeu o limite de {
|
|
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