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:
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -210,7 +210,7 @@ async function downloadAndInstall(manifest, onProgress) {
|
||||
|
||||
setTimeout(() => {
|
||||
app.quit();
|
||||
}, 400);
|
||||
}, 2500);
|
||||
|
||||
return { ok: true, destPath };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user