"""run_job.py — esecuzione isolata di un singolo job, come processo OS a se'. Invocazione: python run_job.py e' relativo alla root ufficio/ (stessa convenzione di jobs/*.bat di oggi) — questo script vive dentro ufficio/, quindi risolve il path assoluto rispetto alla propria directory padre. Perche' un processo a se' e non un thread dentro lo scheduler (come faceva IndependentJobRunner in job_runner.py): il blocco del poller del 03/09/2026 (causa mai trovata, vedi archivio/Mauro/lavoro/lavoro-python-ecosistema/ tunnel-nave-ufficio.md) aveva come sospetto principale una contesa tra la scrittura Python del file di log (tenuto aperto per l'intera durata del job) e la sincronizzazione Dropbox dello stesso file, dentro lo stesso processo che doveva anche continuare a fare polling. Isolando l'intero ciclo di vita del job (apertura log, subprocess, timeout, chiusura log) in un processo OS indipendente, un eventuale blocco qui non puo' piu' propagarsi al loop di controllo cron di scheduler_locale.py — nel peggiore dei casi si blocca solo questo singolo job. Formato del log INVARIATO rispetto a job_runner.py/IndependentJobRunner di oggi (header + riga finale "=== ESITO: status exit_code=N duration=Ns ===") — e' il contratto che monitor_executions.py e check_job_health.py leggono, non va cambiato. """ import subprocess import sys from datetime import datetime from pathlib import Path import psutil UFFICIO_ROOT = Path(__file__).resolve().parent DEFAULT_TIMEOUT_SECONDS = 3600 def _kill_process_tree(pid: int): try: parent = psutil.Process(pid) children = parent.children(recursive=True) for child in children: try: child.kill() except psutil.NoSuchProcess: pass try: parent.kill() except psutil.NoSuchProcess: pass gone, alive = psutil.wait_procs([parent] + children, timeout=5) for p in alive: try: p.kill() except psutil.NoSuchProcess: pass except psutil.NoSuchProcess: pass def run_job(job_name: str, command: str, timeout_seconds: int, log_dir: Path) -> int: """Esegue (path relativo a UFFICIO_ROOT), scrive il log con header + riga ESITO. Ritorna l'exit code del subprocess (o -1 su timeout/errore interno).""" log_dir.mkdir(parents=True, exist_ok=True) start_time = datetime.now() log_filename = f"{job_name}_{start_time.strftime('%Y%m%d_%H%M%S')}.log" log_file_path = log_dir / log_filename script_path = UFFICIO_ROOT / command try: cmd = f'cmd.exe /c "{script_path}"' with open(log_file_path, "w", encoding="utf-8") as log_file: log_file.write("=== Job Execution Log (gate-ufficio) ===\n") log_file.write(f"Job: {job_name}\n") log_file.write(f"Start Time: {start_time.strftime('%Y-%m-%d %H:%M:%S')}\n") log_file.write(f"Command: {cmd}\n") log_file.write("=" * 50 + "\n\n") log_file.flush() process = subprocess.Popen( cmd, stdout=log_file, stderr=subprocess.STDOUT, text=True, ) try: exit_code = process.wait(timeout=timeout_seconds) status = "completed" if exit_code == 0 else "failed" except subprocess.TimeoutExpired: _kill_process_tree(process.pid) exit_code = -1 status = "timeout" duration = (datetime.now() - start_time).total_seconds() log_file.write( f"\n=== ESITO: {status} exit_code={exit_code} duration={duration:.1f}s ===\n" ) return exit_code except Exception as e: duration = (datetime.now() - start_time).total_seconds() with open(log_file_path, "a", encoding="utf-8") as log_file: log_file.write(f"\n\n{'=' * 50}\nEXECUTION ERROR: {e}\n{'=' * 50}\n") log_file.write(f"\n=== ESITO: failed exit_code=-1 duration={duration:.1f}s ===\n") return -1 def main(): if len(sys.argv) < 4: print("Uso: run_job.py [log_dir]", file=sys.stderr) sys.exit(2) job_name = sys.argv[1] command = sys.argv[2] timeout_seconds = int(sys.argv[3]) if sys.argv[3] else DEFAULT_TIMEOUT_SECONDS log_dir = Path(sys.argv[4]) if len(sys.argv) > 4 else UFFICIO_ROOT / "logs" run_job(job_name, command, timeout_seconds, log_dir) # Il wrapper esce sempre 0 — nessuno aspetta questo processo (fire-and-forget), # l'esito vero del job vive solo nella riga ESITO del log. if __name__ == "__main__": main()