JARVIS/nucleo/manos/tareas.py
sito 8e4bc8ad94 JARVIS: asistente de voz local para Linux
Nucleo propio: oye con whisper.cpp, piensa con un modelo de Ollama, habla
con Piper, y hace RAG sobre los apuntes del usuario. 100% local, sin
cuentas ni claves.

Escrito bajo una restriccion dura, 4 GB de VRAM: el cerebro y whisper
comparten tarjeta y solo caben porque estan dimensionados para ello. El
RAG usa embeddings estaticos con busqueda hibrida; la voz clonada se sirve
de una cache de frases.

Incluye instalador (install.sh), requisitos, y documentacion del stack,
del manejo de root y de las acciones. Los apuntes indexados y el diario NO
se incluyen: son privados y el .gitignore los bloquea.
2026-08-16 15:34:37 +02:00

346 lines
12 KiB
Python

"""Tareas en paralelo: cada orden corre por su cuenta y se puede parar.
Hasta ahora una orden era una llamada a `ejecuta()`: un `subprocess.run` con
veinte segundos de limite que bloqueaba el hilo hasta terminar. Servia porque
todos los atajos eran instantaneos, pero deja fuera justo lo que uno querria
mandar y olvidarse: un escaneo, una busqueda por todo el disco, una descarga.
Y no habia forma de arrepentirse a mitad.
Aqui cada orden es una Tarea con nombre, estado y un proceso propio del que se
guarda el grupo, para poder matarlo entero. Tres cosas que costaron decidir:
- **Se mata el grupo, no el proceso.** El comando real es `bash -lc "..."`, asi
que matar el bash deja vivos a sus hijos —el `find`, el `nmap`— corriendo y
comiendo disco con la tarjeta ya cerrada. Con `start_new_session=True` el
proceso estrena grupo y `killpg` se los lleva a todos.
- **Fuera del sandbox tambien vale.** Comprobado: matar el `flatpak-spawn`
intermediario propaga la señal al proceso del host (2 procesos vivos -> 0).
No hace falta perseguir el PID del otro lado.
- **La salida se lee segun sale, no al final.** Con `run()` no hay nada que
enseñar hasta que termina, y una tarjeta de un escaneo de tres minutos que
esta en blanco los tres minutos no informa de nada. Se lee linea a linea y se
guardan las ultimas, que es lo que cabe en la tarjeta.
"""
import os
import shlex
import signal
import subprocess
import threading
import time
import urllib.parse
# Lo que se guarda de la salida para enseñar en la tarjeta y para la plantilla
# de respuesta. Una tarjeta pequeña no da para mas, y guardar la salida entera
# de un `find /` es meterse megas en memoria por nada.
LINEAS_VISTA = 6
SALIDA_MAXIMA = 600
# Con boton de parar, el limite deja de ser una proteccion y pasa a ser un
# estorbo: veinte segundos matan cualquier escaneo util. Se deja un tope alto
# como red para lo que se cuelgue de verdad y no vuelva nunca.
LIMITE_LARGO = 900 # 15 minutos
# Cada cuanto se avisa al HUD mientras sale texto. Sin freno, un comando
# hablador manda cientos de mensajes por segundo al WebView y la interfaz se
# atasca dibujando texto que nadie llega a leer.
CADENCIA_AVISO = 0.25
CORRIENDO, HECHA, PARADA, FALLO, CADUCADA = (
"corriendo", "hecha", "parada", "fallo", "caducada")
class Tarea:
"""Una orden en marcha. Solo datos; quien la mueve es el gestor."""
def __init__(self, ident, titulo, accion_id, comando):
self.id = ident
self.titulo = titulo
self.accion_id = accion_id
self.comando = comando
# La accion de origen viaja con la tarea (y no en un diccionario
# aparte del panel) porque una tarea sin comando termina DENTRO de
# lanza(), antes de que quien la lanzo tenga el id para apuntarla: el
# aviso de "terminada" llegaria a un registro todavia vacio.
self.accion = None
self.argumento = ""
self.estado = CORRIENDO
self.inicio = time.monotonic()
self.fin = None
self.lineas = [] # las ultimas LINEAS_VISTA de salida
self.salida = "" # la salida acumulada, recortada
self.codigo = None
self._proc = None
@property
def segundos(self) -> float:
return (self.fin or time.monotonic()) - self.inicio
@property
def viva(self) -> bool:
return self.estado == CORRIENDO
def resumen(self) -> dict:
"""Lo que viaja al HUD. Sin objetos: esto acaba en un json.dumps."""
return {
"id": self.id,
"titulo": self.titulo,
"accion": self.accion_id,
"estado": self.estado,
"segundos": round(self.segundos, 1),
"lineas": self.lineas[-LINEAS_VISTA:],
"codigo": self.codigo,
}
class GestorTareas:
"""Lanza, vigila y para tareas. Un unico sitio donde vive el registro.
al_cambiar(tarea) se llama desde el hilo de la tarea cada vez que hay algo
nuevo que enseñar. Quien lo reciba se encarga de saltar al hilo de la
interfaz: aqui no se sabe nada de GTK a proposito, para poder probarlo sin
levantar la aplicacion.
"""
def __init__(self, al_cambiar=None, prefijo_host=()):
self._al_cambiar = al_cambiar or (lambda tarea: None)
self._prefijo_host = _con_watch_bus(prefijo_host)
self._tareas = {}
self._siguiente = 0
self._cerrojo = threading.Lock()
# -------------------------------------------------------------- registro
def _nueva(self, titulo, accion_id, comando) -> Tarea:
with self._cerrojo:
self._siguiente += 1
ident = f"t{self._siguiente}"
tarea = Tarea(ident, titulo, accion_id, comando)
self._tareas[ident] = tarea
return tarea
def get(self, ident):
with self._cerrojo:
return self._tareas.get(ident)
def activas(self) -> list:
with self._cerrojo:
return [t for t in self._tareas.values() if t.viva]
def todas(self) -> list:
with self._cerrojo:
return list(self._tareas.values())
def _avisa(self, tarea):
try:
self._al_cambiar(tarea)
except Exception as e: # el HUD no tumba la tarea
print(f"JARVIS: aviso de tarea fallido: {e}")
# ---------------------------------------------------------------- lanzar
def lanza(self, accion, argumento="", titulo=None) -> Tarea:
"""Arranca la accion en su propio proceso y devuelve la tarea ya viva."""
comando = accion.comando or ""
if comando:
comando = comando.replace("{argumento}", shlex.quote(argumento))
comando = comando.replace(
"{argumento_url}",
shlex.quote(urllib.parse.quote_plus(argumento)))
tarea = self._nueva(titulo or _titulo_de(accion, argumento),
accion.id, comando)
tarea.accion = accion
tarea.argumento = argumento
self._avisa(tarea)
if not comando:
# Acciones que solo hablan (no tienen comando): nacen terminadas.
self.termina(tarea.id, HECHA, "")
return tarea
orden = ["bash", "-lc", comando]
if accion.host:
orden = self._prefijo_host + orden
threading.Thread(target=self._corre, args=(tarea, orden),
daemon=True).start()
return tarea
def adopta(self, titulo, accion_id="") -> Tarea:
"""Una tarjeta para trabajo que se ejecuta fuera del gestor.
Lo que pasa por el dialogo de root corre por su camino (GestorRoot, con
su contraseña), pero se merece tarjeta igual: si no, pedir algo con
sudo hace desaparecer la señal de que hay algo en marcha.
"""
tarea = self._nueva(titulo, accion_id, "")
self._avisa(tarea)
return tarea
def _corre(self, tarea, orden):
try:
# start_new_session: el proceso estrena grupo, para poder matar el
# arbol entero y no solo el bash de arriba.
proc = subprocess.Popen(
orden, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, errors="replace", start_new_session=True)
except OSError as e:
self.termina(tarea.id, FALLO, f"No he podido ejecutarlo: {e}")
return
tarea._proc = proc
limite = threading.Timer(LIMITE_LARGO, self._caduca, args=(tarea.id,))
limite.daemon = True
limite.start()
ultimo_aviso = 0.0
acumulado = []
try:
for linea in proc.stdout:
linea = linea.rstrip("\n")
if not linea.strip():
continue
acumulado.append(linea)
tarea.lineas = acumulado[-LINEAS_VISTA:]
ahora = time.monotonic()
if ahora - ultimo_aviso >= CADENCIA_AVISO:
ultimo_aviso = ahora
self._avisa(tarea)
except Exception as e:
print(f"JARVIS: leyendo la salida de {tarea.id}: {e}")
finally:
limite.cancel()
codigo = proc.wait()
salida = "\n".join(acumulado).strip()
if tarea.estado == PARADA: # la paro alguien: no se toca
self._avisa(tarea)
return
if tarea.estado == CADUCADA:
self._avisa(tarea)
return
self.termina(tarea.id, HECHA if codigo == 0 else FALLO, salida, codigo)
# ------------------------------------------------------------- terminar
def termina(self, ident, estado, salida="", codigo=None):
tarea = self.get(ident)
if tarea is None:
return
tarea.estado = estado
tarea.fin = time.monotonic()
tarea.codigo = codigo
if salida:
tarea.salida = salida[:SALIDA_MAXIMA]
if not tarea.lineas:
tarea.lineas = salida.splitlines()[-LINEAS_VISTA:]
else:
tarea.salida = "\n".join(tarea.lineas)[:SALIDA_MAXIMA]
self._avisa(tarea)
def para(self, ident) -> bool:
"""Corta la tarea. Primero por las buenas, luego a la fuerza."""
tarea = self.get(ident)
if tarea is None or not tarea.viva:
return False
tarea.estado = PARADA
proc = tarea._proc
if proc is not None and proc.poll() is None:
_mata_grupo(proc)
tarea.fin = time.monotonic()
self._avisa(tarea)
return True
def _caduca(self, ident):
tarea = self.get(ident)
if tarea is None or not tarea.viva:
return
tarea.estado = CADUCADA
if tarea._proc is not None and tarea._proc.poll() is None:
_mata_grupo(tarea._proc)
tarea.fin = time.monotonic()
self._avisa(tarea)
def cierra(self, ident) -> bool:
"""Quita la tarjeta. Si seguia viva, la para antes: cerrar es cerrar."""
tarea = self.get(ident)
if tarea is None:
return False
if tarea.viva:
self.para(ident)
with self._cerrojo:
self._tareas.pop(ident, None)
return True
def para_todas(self):
for tarea in self.activas():
self.para(tarea.id)
def _con_watch_bus(prefijo) -> list:
"""Que lo lanzado fuera del sandbox no sobreviva a la aplicacion.
Una tarea larga corre en el host por flatpak-spawn, y ahi el proceso ya no
es hijo de nadie que se entere de que la app se ha cerrado: un escaneo
lanzado y olvidado seguiria comiendo maquina despues de cerrar la ventana,
sin ventana desde la que pararlo. `--watch-bus` ata su vida a la conexion
de bus de quien lo lanzo, que es justo lo que se quiere.
"""
prefijo = list(prefijo)
if not prefijo:
return prefijo
if any("flatpak-spawn" in p for p in prefijo) and "--watch-bus" not in prefijo:
# detras del propio flatpak-spawn, antes de sus argumentos
for i, p in enumerate(prefijo):
if "flatpak-spawn" in p:
prefijo.insert(i + 1, "--watch-bus")
break
return prefijo
def _mata_grupo(proc):
"""SIGTERM al grupo, y SIGKILL a los tres segundos si sigue ahi.
Se da margen a proposito: un `wget` a medias cierra el fichero y un `nmap`
escupe lo que llevaba. Matar a lo bruto de entrada deja restos.
"""
try:
grupo = os.getpgid(proc.pid)
except OSError:
return
try:
os.killpg(grupo, signal.SIGTERM)
except OSError:
return
def remata():
if proc.poll() is None:
try:
os.killpg(grupo, signal.SIGKILL)
except OSError:
pass
t = threading.Timer(3.0, remata)
t.daemon = True
t.start()
def _titulo_de(accion, argumento) -> str:
"""El rotulo de la tarjeta: corto, y con el argumento si lo hay.
El id de la accion con guiones bajos ("buscar_fichero") es de programador;
en la tarjeta se lee "buscar fichero", y con lo que se pidio detras, que es
lo que distingue dos busquedas a la vez.
"""
base = (accion.id or "accion").replace("_", " ")
if argumento:
arg = argumento.strip()
if len(arg) > 22:
arg = arg[:21] + ""
return f"{base}: {arg}"
return base