feat: Implement job abandonment logic for reconnected workers in bot services. Introduce a mechanism to cancel pending and running jobs when a worker reconnects, ensuring a fresh start for new jobs. Update tests to verify job cancellation behavior upon worker re-registration. Enhance UI components in desktop application for better user experience and clarity on bot status.

This commit is contained in:
erik
2026-08-12 16:37:12 -05:00
parent e9b766281a
commit 343ffb8ebe
9 changed files with 271 additions and 127 deletions
@@ -281,6 +281,7 @@ class BotDashboard(ctk.CTk):
self.entry_api.delete(0, "end")
self.entry_api.insert(0, api)
self._persist_flags()
self._limpiar_logs()
self._agent = WorkerAgent(
api_url=api,
@@ -291,6 +291,7 @@ class BotDashboard(ctk.CTk):
self.entry_api.delete(0, "end")
self.entry_api.insert(0, api)
self._persist_flags()
self._limpiar_logs()
self._agent = WorkerAgent(
api_url=api,
+67 -1
View File
@@ -27,6 +27,8 @@ from infrastructure.database import (
from infrastructure.storage_minio import downloads_prefix
HEARTBEAT_OFFLINE_SECONDS = 45
# Un agent recién conectado no recibe jobs reclamados de otros workers.
_FRESH_WORKER_GRACE_SECONDS = 45
def _parse_iso(value: str | None) -> datetime | None:
@@ -44,6 +46,7 @@ class JobStore:
def __init__(self, db_path: str | None = None):
# db_path ignorado (compat); usa DATABASE_URL.
init_db()
self._fresh_until: dict[str, datetime] = {}
def _row_worker(self, row: DownloadWorkerRow) -> Worker:
worker = Worker(
@@ -120,7 +123,67 @@ class JobStore:
)
session.add(row)
session.flush()
return self._row_worker(row)
parent_ids = self._abandon_assigned_jobs(
session,
worker_id,
now,
"Agent reconectado: job anterior abandonado. Lanza una corrida nueva desde el desktop.",
)
session.flush()
worker = self._row_worker(row)
self._fresh_until[worker_id] = now + timedelta(seconds=_FRESH_WORKER_GRACE_SECONDS)
for parent_id in parent_ids:
try:
self.refresh_parent_from_shards(parent_id)
except Exception:
pass
return worker
def _abandon_assigned_jobs(
self, session, worker_id: str, now: datetime, message: str
) -> list[str]:
"""Cancela pending/running de este worker para que un agent nuevo arranque idle."""
rows = session.scalars(
select(DownloadJobRow).where(
DownloadJobRow.worker_id == worker_id,
DownloadJobRow.status.in_((JobStatus.PENDING.value, JobStatus.RUNNING.value)),
)
).all()
parent_ids: set[str] = set()
for row in rows:
is_parent = (
row.parent_download_job_id is None
and row.shard_count is not None
and int(row.shard_count) > 1
)
if is_parent:
continue
row.status = JobStatus.CANCELLED.value
row.cancel_requested = True
row.finished_at = now
row.error = message
self._sync_task(session, row, status=JobStatus.CANCELLED.value, error=message)
session.add(
DownloadJobEventRow(
download_job_id=row.id,
event_type="warning",
message=message,
timestamp=now,
metadata_json={"abandoned_on_register": True, "worker_id": worker_id},
)
)
if row.parent_download_job_id:
parent_ids.add(row.parent_download_job_id)
session.add(
DownloadJobEventRow(
download_job_id=row.parent_download_job_id,
event_type="warning",
message=f"Shard {row.shard_index} abandonado (agent reconectado)",
timestamp=now,
metadata_json={"shard_id": row.id, "worker_id": worker_id},
)
)
return list(parent_ids)
def heartbeat(self, worker_id: str) -> Optional[Worker]:
with session_scope() as session:
@@ -609,6 +672,9 @@ class JobStore:
w.current_job_id = None
w.status = WorkerStatus.OFFLINE.value
elif not w.current_job_id:
fresh_until = self._fresh_until.get(w.id)
if fresh_until is not None and now < fresh_until:
continue
online_free.append(w)
if not offline_ids:
+26
View File
@@ -80,6 +80,32 @@ def test_create_job_requires_worker_and_claim_is_atomic(store):
assert other is None
def test_reregister_abandons_leftover_job(store):
job_store, JobStatus, _WorkerStatus = store
job_store.register_worker("PC-01")
job = job_store.create_job(
worker_id="PC-01",
excel_key="uploads/x/input.xlsx",
original_filename="datos.xlsx",
)
job_store.claim_pending("PC-01")
job_store.register_worker("PC-01")
leftover = job_store.get_job(job.id)
assert leftover is not None
assert leftover.status == JobStatus.CANCELLED.value
assert job_store.claim_pending("PC-01") is None
fresh = job_store.create_job(
worker_id="PC-01",
excel_key="uploads/y/input.xlsx",
original_filename="nuevo.xlsx",
)
claimed = job_store.claim_pending("PC-01")
assert claimed is not None
assert claimed.id == fresh.id
def test_cancel_pending_marks_cancelled(store):
job_store, JobStatus, _WorkerStatus = store
job_store.register_worker("PC-01")
+66 -1
View File
@@ -27,6 +27,7 @@ from infrastructure.database import (
from infrastructure.storage_minio import reports_prefix
HEARTBEAT_OFFLINE_SECONDS = 45
_FRESH_WORKER_GRACE_SECONDS = 45
def _parse_iso(value: str | None) -> datetime | None:
@@ -44,6 +45,7 @@ class JobStore:
def __init__(self, db_path: str | None = None):
# db_path ignorado (compat); usa DATABASE_URL.
init_db()
self._fresh_until: dict[str, datetime] = {}
def _row_worker(self, row: DigitadorWorkerRow) -> Worker:
worker = Worker(
@@ -120,7 +122,67 @@ class JobStore:
)
session.add(row)
session.flush()
return self._row_worker(row)
parent_ids = self._abandon_assigned_jobs(
session,
worker_id,
now,
"Agent reconectado: job anterior abandonado. Lanza una corrida nueva desde el desktop.",
)
session.flush()
worker = self._row_worker(row)
self._fresh_until[worker_id] = now + timedelta(seconds=_FRESH_WORKER_GRACE_SECONDS)
for parent_id in parent_ids:
try:
self.refresh_parent_from_shards(parent_id)
except Exception:
pass
return worker
def _abandon_assigned_jobs(
self, session, worker_id: str, now: datetime, message: str
) -> list[str]:
"""Cancela pending/running de este worker para que un agent nuevo arranque idle."""
rows = session.scalars(
select(DigitadorJobRow).where(
DigitadorJobRow.worker_id == worker_id,
DigitadorJobRow.status.in_((JobStatus.PENDING.value, JobStatus.RUNNING.value)),
)
).all()
parent_ids: set[str] = set()
for row in rows:
is_parent = (
row.parent_digitador_job_id is None
and row.shard_count is not None
and int(row.shard_count) > 1
)
if is_parent:
continue
row.status = JobStatus.CANCELLED.value
row.cancel_requested = True
row.finished_at = now
row.error = message
self._sync_task(session, row, status=JobStatus.CANCELLED.value, error=message)
session.add(
DigitadorJobEventRow(
digitador_job_id=row.id,
event_type="warning",
message=message,
timestamp=now,
metadata_json={"abandoned_on_register": True, "worker_id": worker_id},
)
)
if row.parent_digitador_job_id:
parent_ids.add(row.parent_digitador_job_id)
session.add(
DigitadorJobEventRow(
digitador_job_id=row.parent_digitador_job_id,
event_type="warning",
message=f"Shard {row.shard_index} abandonado (agent reconectado)",
timestamp=now,
metadata_json={"shard_id": row.id, "worker_id": worker_id},
)
)
return list(parent_ids)
def heartbeat(self, worker_id: str) -> Optional[Worker]:
with session_scope() as session:
@@ -609,6 +671,9 @@ class JobStore:
w.current_job_id = None
w.status = WorkerStatus.OFFLINE.value
elif not w.current_job_id:
fresh_until = self._fresh_until.get(w.id)
if fresh_until is not None and now < fresh_until:
continue
online_free.append(w)
if not offline_ids:
+1 -1
View File
@@ -210,7 +210,7 @@ async function downloadAndInstall(manifest, onProgress) {
setTimeout(() => {
app.quit();
}, 400);
}, 2500);
return { ok: true, destPath };
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ipsextractor-desktop",
"version": "1.2.0",
"version": "1.3.0",
"private": true,
"description": "Desktop extraction client for IPSExtractor microservices.",
"main": "electron/main.cjs",
@@ -244,7 +244,13 @@ export function useAutoUpdateCheck() {
try {
await window.desktopApi.installUpdate(manifest);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
const msg = e instanceof Error ? e.message : String(e);
if (msg.toLowerCase().includes("aborted")) {
setProgress({ phase: "installing", percent: 100 });
setCheckMessage("El instalador se inició. Si la app no se cierra sola, vuelve a abrirla después de unos segundos.");
return;
}
setError(msg);
setInstalling(false);
}
}
@@ -13,6 +13,7 @@ import {
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ApiClient } from "../../lib/apiClient";
import { formatDateTime } from "../../lib/time";
import type {
DigitadorJob,
DigitadorJobEvent,
@@ -305,21 +306,107 @@ export function DigitadorWizardModule({ client, job, initialSource = null }: Pro
<div className="wiz-body">
{step === 1 && (
<div className="wiz-step">
<div className="wiz-header" style={{ marginBottom: 4 }}>
<h2 style={{ display: "flex", alignItems: "center", gap: 8, margin: 0 }}>
<Keyboard size={18} />
Bot digitador
</h2>
<p className="wiz-desc" style={{ marginTop: 6 }}>
Digita en Integración Total solo los <strong>aptos (PASO_BOT)</strong>, o un CSV
propio con el mismo formato.
<div className="wiz-section">
<div className="wiz-section-header">
<h3 className="wiz-section-title">
Bots disponibles
{onlineWorkers.length > 0 && (
<span className="wiz-count-badge">{onlineWorkers.length} online</span>
)}
</h3>
<button
type="button"
className="btn btn-sm btn-ghost"
onClick={() => void refreshWorkers()}
disabled={refreshing}
>
{refreshing ? <Loader2 size={13} className="spin" /> : <RefreshCw size={13} />}
Actualizar
</button>
</div>
<p className="wiz-desc">
Con varios bots online el CSV se reparte automáticamente; los reportes RESULTADOS /
OMITIDOS quedan en un solo resultado.
</p>
{onlineWorkers.length >= 2 && (
<label
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginTop: 12,
marginBottom: 8,
cursor: "pointer",
}}
>
<input
type="checkbox"
checked={useAllOnline}
onChange={(e) => {
const on = e.target.checked;
setUseAllOnline(on);
if (on) setSelectedIds(onlineWorkers.map((w) => w.id));
}}
/>
<span>Usar todos los bots online ({onlineWorkers.length})</span>
</label>
)}
{workers.length === 0 ? (
<p className="wiz-hint">Ningún worker registrado. Enciende el agent en el PC del bot.</p>
) : (
<div className="wiz-review-grid" style={{ marginTop: 12 }}>
{workers.map((w) => {
const online = w.status === "online" || w.status === "busy";
const selected = effectiveTargets.includes(w.id);
return (
<button
key={w.id}
type="button"
className={`wiz-review-item${selected ? " wiz-review-item--selected" : ""}`}
disabled={!online || useAllOnline}
onClick={() => online && toggleWorker(w.id)}
style={{
textAlign: "left",
cursor: online && !useAllOnline ? "pointer" : "not-allowed",
opacity: online ? 1 : 0.5,
borderColor: selected ? "var(--accent, #3b8ed0)" : undefined,
}}
>
<div>
<span className="wiz-review-label">Worker</span>
<span className="wiz-review-value">{w.id}</span>
</div>
<div>
<span className="wiz-review-label">Estado</span>
<span className="wiz-review-value">
{w.status}
{w.hostname ? ` · ${w.hostname}` : ""}
{selected ? " · seleccionado" : ""}
</span>
</div>
</button>
);
})}
</div>
)}
{onlineWorkers.length === 0 && workers.length > 0 && (
<p className="wiz-hint">No hay bots online. Inicia el agent en la PC correspondiente.</p>
)}
{effectiveTargets.length > 1 && (
<p className="wiz-hint" style={{ marginTop: 8 }}>
Se repartirá entre {effectiveTargets.length} bots.
</p>
)}
</div>
{/* 1. Origen */}
<div className="wiz-section">
<h3 className="wiz-section-title">1. Origen</h3>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginBottom: 14 }}>
<h3 className="wiz-section-title">Origen del CSV</h3>
<p className="wiz-desc">
Solo aptos (PASO_BOT) de población apta, o un CSV propio con el mismo formato.
</p>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 8, marginBottom: 14 }}>
<button
type="button"
className={`btn${sourceMode === "aptos" ? " btn-primary" : ""}`}
@@ -385,9 +472,10 @@ export function DigitadorWizardModule({ client, job, initialSource = null }: Pro
>
{paJobs.map((pa) => {
const n = Number(pa.summary?.paso_bot ?? 0);
const when = formatDateTime(pa.finished_at || pa.created_at);
return (
<option key={pa.id} value={pa.id}>
{pa.id.slice(0, 8)} · {n} aptos · {pa.summary?.total ?? 0} total
{when} · {n} aptos · {pa.summary?.total ?? 0} total · {pa.id.slice(0, 8)}
</option>
);
})}
@@ -416,7 +504,7 @@ export function DigitadorWizardModule({ client, job, initialSource = null }: Pro
: "Sin aptos en esta corrida"}
</div>
<div className="wiz-hint" style={{ margin: 0 }}>
Solo se envían filas PASO_BOT. Los no aptos no se digitan.
Solo se envían filas PASO_BOT.
</div>
</div>
</div>
@@ -462,115 +550,6 @@ export function DigitadorWizardModule({ client, job, initialSource = null }: Pro
</>
)}
</div>
{/* 2. Bots */}
<div className="wiz-section">
<div className="wiz-section-header">
<h3 className="wiz-section-title">
2. Bots
{onlineWorkers.length > 0 && (
<span className="wiz-count-badge">{onlineWorkers.length} online</span>
)}
</h3>
<button
type="button"
className="btn btn-sm btn-ghost"
onClick={() => void refreshWorkers()}
disabled={refreshing}
>
{refreshing ? <Loader2 size={13} className="spin" /> : <RefreshCw size={13} />}
Actualizar
</button>
</div>
<p className="wiz-desc">
El agent debe estar encendido en la PC con Integración Total. Con varios bots, las
filas se reparte automáticamente.
</p>
{onlineWorkers.length >= 2 && (
<label
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginTop: 12,
marginBottom: 8,
cursor: "pointer",
}}
>
<input
type="checkbox"
checked={useAllOnline}
onChange={(e) => {
const on = e.target.checked;
setUseAllOnline(on);
if (on) setSelectedIds(onlineWorkers.map((w) => w.id));
}}
/>
<span>Usar todos los bots online ({onlineWorkers.length})</span>
</label>
)}
{workers.length === 0 ? (
<div className="wiz-estimate-box" style={{ marginTop: 12, textAlign: "left" }}>
<div style={{ display: "flex", alignItems: "flex-start", gap: 10 }}>
<Bot size={18} style={{ flexShrink: 0, marginTop: 2 }} />
<div>
<div style={{ fontWeight: 600 }}>Ningún bot conectado</div>
<p className="wiz-hint" style={{ margin: "4px 0 0" }}>
En la PC del digitador ejecuta <code>run_gui.py</code> del agent y conéctalo
al gateway.
</p>
</div>
</div>
</div>
) : (
<div className="wiz-review-grid" style={{ marginTop: 12 }}>
{workers.map((w) => {
const online = w.status === "online" || w.status === "busy";
const selected = effectiveTargets.includes(w.id);
return (
<button
key={w.id}
type="button"
className={`wiz-review-item${selected ? " wiz-review-item--selected" : ""}`}
disabled={!online || useAllOnline}
onClick={() => online && toggleWorker(w.id)}
style={{
textAlign: "left",
cursor: online && !useAllOnline ? "pointer" : "not-allowed",
opacity: online ? 1 : 0.45,
borderColor: selected ? "var(--accent, #3b8ed0)" : undefined,
}}
>
<div>
<span className="wiz-review-label">Worker</span>
<span className="wiz-review-value">{w.id}</span>
</div>
<div>
<span className="wiz-review-label">Estado</span>
<span className="wiz-review-value">
{w.status}
{w.hostname ? ` · ${w.hostname}` : ""}
{selected ? " · seleccionado" : ""}
</span>
</div>
</button>
);
})}
</div>
)}
{onlineWorkers.length === 0 && workers.length > 0 && (
<p className="wiz-hint" style={{ marginTop: 8 }}>
Hay workers registrados pero ninguno online. Revisa el agent.
</p>
)}
{effectiveTargets.length > 1 && (
<p className="wiz-hint" style={{ marginTop: 8 }}>
Se repartirá entre {effectiveTargets.length} bots.
</p>
)}
</div>
</div>
)}