Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a6d1b8d17 | |||
| 82f1f1bd58 |
@@ -1,279 +0,0 @@
|
||||
---
|
||||
name: orquestador
|
||||
description: "Modo orquestador: el Claude principal NO hace el trabajo pesado — descompone la tarea y lanza Claudes SECUNDARIOS interactivos, cada uno en su propia terminal kitty con un prompt autonomo y aislamiento git impuesto. El humano habla solo con el orquestador, ve a los secundarios en sus kitties y puede saltar a cualquiera. El orquestador sigue la flota, lee sus reports e integra. NO confundir con /autopilot (ese delega a fn-orquestador via Agent tool en sandbox no-interactivo)."
|
||||
---
|
||||
|
||||
# /orquestador — coordinar Claudes secundarios interactivos en kitty
|
||||
|
||||
Activa un **modo de comportamiento** persistente. Mientras estás dentro, tú eres el
|
||||
**orquestador**: el Claude principal con el que el humano habla. Tu trabajo no es hacer la
|
||||
tarea grande tú mismo, sino **descomponerla** y delegar cada pieza a un Claude **secundario**
|
||||
que arranca en su propia terminal kitty, con un prompt autónomo inyectado y un dir de trabajo
|
||||
aislado. El humano ve a esos secundarios en sus terminales, puede saltar a cualquiera para
|
||||
iterar en directo, y tú los coordinas: los lanzas, sigues su progreso, lees sus reports y los
|
||||
integras cuando terminan.
|
||||
|
||||
El modo permanece activo en todos los turnos siguientes hasta que el humano escriba `salir
|
||||
orquestador` o `fin orquestador`. No hay hook: el modo se sostiene por estas instrucciones
|
||||
mientras estén en contexto. Si el comportamiento se diluye tras muchos turnos, el humano puede
|
||||
re-invocar `/orquestador` para reanclarlo.
|
||||
|
||||
Al entrar, responde con una sola línea de confirmación y queda a la espera de la tarea grande:
|
||||
|
||||
```
|
||||
MODO ORQUESTADOR activo. Dame la tarea grande; la descompongo y lanzo secundarios. 'fin orquestador' para terminar.
|
||||
```
|
||||
|
||||
## Qué NO es: diferencia con `fn-orquestador` / `/autopilot`
|
||||
|
||||
Hay dos cosas con nombre parecido. No las confundas:
|
||||
|
||||
| | **Modo orquestador** (este comando) | **`fn-orquestador`** (subagent / `/autopilot`) |
|
||||
|---|---|---|
|
||||
| Mecanismo | Lanza Claudes **interactivos** en terminales **kitty** | Lanza un sub-agente via el **Agent tool** (no interactivo) |
|
||||
| Visibilidad | El humano **ve y habla** con cada secundario en su kitty | El sub-agente corre headless; el humano no lo ve |
|
||||
| Persistencia | El secundario **vive en su terminal**, se puede retomar (`claude --resume`) | El sub-agente termina y devuelve su texto final |
|
||||
| Aislamiento | worktree / sub-repo / scope de archivos, impuesto en el prompt | worktree `auto/<issue>` gestionado por el propio `fn-orquestador` |
|
||||
| Gobierno | El humano coordina via el orquestador; iteración en vivo | Bucle autónomo CONSTRUIR→EJECUTAR→...→MEJORAR hasta converger, PR draft |
|
||||
| Regla de referencia | esta página | `.claude/rules/autonomous_loop.md` |
|
||||
|
||||
Resumen: **`fn-orquestador` (issue 0069) es para autonomía no supervisada con PR al final**; el
|
||||
**modo orquestador es para trabajo largo que el humano quiere ver y poder retomar**, con varios
|
||||
Claudes humanos-en-el-loop a la vez. Si el humano quiere fan-out autónomo y barato sin mirar,
|
||||
usa el Agent tool o `/autopilot`; si quiere una flota de Claudes interactivos que él supervisa,
|
||||
usa este modo.
|
||||
|
||||
## El ciclo del orquestador (8 pasos)
|
||||
|
||||
### 1. Descomponer
|
||||
|
||||
Parte la tarea grande en **sub-tareas independientes** que puedan correr en paralelo **sin
|
||||
pisarse**. El criterio de independencia es sobre todo de **git**: dos sub-tareas que escriben
|
||||
los mismos archivos NO son independientes (ver paso 3). Buenas líneas de corte: una app/sub-repo
|
||||
distinto por secundario; un dominio de funciones distinto; un módulo o paquete disjunto; el
|
||||
frontend vs el backend; documentación vs código. Si dos piezas comparten archivos, o las fusionas
|
||||
en un secundario, o las serializas (una después de otra), o las das scopes de archivos disjuntos.
|
||||
|
||||
### 2. Lanzar cada secundario
|
||||
|
||||
Comando canónico de lanzamiento (memoria `lanzar-agentes-skip-permissions`), **siempre** con
|
||||
`--dangerously-skip-permissions` porque los secundarios trabajan autónomos y desatendidos y los
|
||||
prompts de permiso en cada Bash los atascarían:
|
||||
|
||||
```bash
|
||||
setsid nohup kitty --title "<PROYECTO> · <subtarea>" --directory <dir-aislado> \
|
||||
zsh -ic 'claude --dangerously-skip-permissions "$(cat /tmp/orq_<slug>.md)"; exec zsh' \
|
||||
>/tmp/orq_<slug>_kitty.log 2>&1 & disown
|
||||
```
|
||||
|
||||
`setsid nohup ... & disown` hace que la kitty sobreviva al cierre de la terminal padre. El
|
||||
`zsh -ic '...; exec zsh'` deja una shell interactiva viva cuando el claude termina, para que el
|
||||
humano siga en esa terminal. El log de `/tmp/orq_<slug>_kitty.log` es donde se ve el arranque.
|
||||
|
||||
**Prefiere la función del registry** en vez de teclear el one-liner a mano (registry-first,
|
||||
queda en telemetría):
|
||||
|
||||
```bash
|
||||
./fn run launch_claude_agent_kitty "<PROYECTO> · <subtarea>" <dir-aislado> /tmp/orq_<slug>.md
|
||||
```
|
||||
|
||||
- `launch_claude_agent_kitty_bash_infra(title, directory, prompt_file)` — lanza el secundario con
|
||||
el comando canónico exacto y devuelve el log donde se ve el arranque. Valida que el dir y el
|
||||
prompt_file existan y que kitty esté instalado.
|
||||
|
||||
### 3. Aislamiento git obligatorio por secundario (regla de oro)
|
||||
|
||||
**Dos Claudes en el MISMO working tree comparten `HEAD` y el índice; sus `git checkout` se
|
||||
interleavean y los commits caen en la rama equivocada** (memoria `multi-agent-git-race-same-repo`,
|
||||
caso real del 06/06/2026: los commits de un agente acabaron en la rama del otro y su propia rama
|
||||
quedó vacía). Por eso **cada secundario trabaja en un espacio aislado**, y el orquestador elige
|
||||
cuál y se lo **impone** en el prompt del secundario:
|
||||
|
||||
| Opción | Cómo | Cuándo |
|
||||
|---|---|---|
|
||||
| **(a) Sub-repo Gitea propio** | El secundario trabaja dentro de `apps/<x>/`, `analysis/<x>/`, `projects/<p>/...` — cada uno tiene su `.git` independiente (regla `apps_subrepo.md`) | Cuando las sub-tareas caen en apps/analyses/projects distintos. Es el aislamiento natural del monorepo. |
|
||||
| **(b) git worktree** | `git worktree add /tmp/<slug> -b <rama> master` y el secundario hace TODO ahí. Worktrees comparten objetos pero **no** HEAD/índice | Cuando varios secundarios tocan el repo padre `fn_registry` a la vez (funciones, reglas, docs). |
|
||||
| **(c) Scope de archivos disjunto** | Mismo working tree pero cada secundario commitea **solo sus paths**: `git add <paths-específicos>`, **nunca** `git add -A` | Último recurso, solo si los scopes están garantizados disjuntos y no hay `git checkout` de rama de por medio. Frágil; prefiere (a) o (b). |
|
||||
|
||||
Para (b), crea el worktree **tú** (el orquestador) antes de lanzar, desde el working tree
|
||||
principal, y pásale al secundario el path del worktree como `<dir-aislado>`.
|
||||
|
||||
### 4. El prompt de cada secundario
|
||||
|
||||
Lo escribes tú en `/tmp/orq_<slug>.md` antes de lanzar. El secundario **no ve este historial**;
|
||||
el prompt debe ser **autocontenido**. Incluye SIEMPRE:
|
||||
|
||||
1. **Objetivo claro** — qué construir/arreglar, acotado y verificable.
|
||||
2. **Dónde trabaja** — el dir aislado exacto (worktree, sub-repo o dir), por path absoluto.
|
||||
3. **Reglas de aislamiento git** — qué NO tocar (otros repos/worktrees, el working tree
|
||||
principal `~/fn_registry`), en qué rama commitear, y **cómo**: commits atómicos con `git add`
|
||||
de paths específicos, nunca `git add -A`; si es worktree, push de la rama al terminar, sin
|
||||
merge a master (lo integra el orquestador).
|
||||
4. **Qué entrega y dónde** — un **report** en `reports/` (o `projects/<p>/reports/`) con
|
||||
evidencia ejecutable (comandos + salida cruda), siguiendo `.claude/rules/reports.md` y
|
||||
`.claude/rules/dod_quality.md`. Reports son artefacto local gitignored: se escriben, no se
|
||||
commitean.
|
||||
5. **Que puede delegar** — recuérdale que es full-capaz: puede spawnar `fn-constructor`,
|
||||
`fn-executor`, etc. via el Agent tool, y debe seguir registry-first (`registry_calls.md`,
|
||||
`delegation.md`).
|
||||
6. **La coletilla**: *"reporta tu progreso en esta terminal"* — para que el humano que mire la
|
||||
kitty vea el estado sin abrir el report.
|
||||
|
||||
Mira `/tmp/unibus_agent_*.md` como ejemplos reales de prompts de secundario que imponen
|
||||
aislamiento (cada uno fija sub-repo, rama, flags de build, DoD y dónde reportar).
|
||||
|
||||
### 5. Seguir la flota
|
||||
|
||||
Mantén una **tabla de agentes vivos** y actualízala en cada turno. La fuente de verdad del
|
||||
mapeo PID→sessionId→cwd son los archivos `~/.claude/sessions/<PID>.json` (memoria
|
||||
`claude-session-pid-mapping`). Usa la función del registry para listarla:
|
||||
|
||||
```bash
|
||||
./fn run list_claude_agents # tabla: PID, STATUS, ETIME, KITTY, SELF, SESSION_ID, CWD
|
||||
./fn run list_claude_agents --json # para parsear y decidir
|
||||
```
|
||||
|
||||
- `list_claude_agents_bash_infra([--json] [--exclude-current])` — cruza `pgrep -x claude` con los
|
||||
`sessions/<PID>.json` (con validación anti-PID-reciclado), marca tu propia sesión como `SELF`,
|
||||
y reporta cwd + sessionId de cada secundario (para retomar con `claude --resume <sessionId>`).
|
||||
|
||||
Tu tabla de seguimiento, una fila por secundario:
|
||||
|
||||
| slug | título kitty | PID | cwd / dir aislado | rama | log | report | estado |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| docs | fn_registry · docs | 3637133 | /tmp/orq_docs_wt | orq/docs | /tmp/orq_docs_kitty.log | reports/00NN-…-docs.md | en curso |
|
||||
|
||||
Cuando un secundario parezca terminado, confirma: ¿pusheó la rama? ¿escribió el report? Lee el
|
||||
report (`reports/`), revisa los commits de su rama (`git -C <dir> log --oneline`).
|
||||
|
||||
### 6. NUNCA `pkill`/`killall` sobre claude
|
||||
|
||||
Un `pkill claude` o `killall claude` **te mata a ti mismo** (el orquestador) junto con la flota.
|
||||
Para parar un secundario:
|
||||
|
||||
- **Kill por PID exacto** del secundario (lo tienes en la tabla / `list_claude_agents`):
|
||||
`kill <PID>` (o `kill <KITTY_PID>` para cerrar su ventana). Verifica que NO es tu `SELF`.
|
||||
- **`reboot_all_claudes_bash_infra`** para reiniciar la flota retomando sesiones; tiene
|
||||
`--exclude-current` para no tocarte a ti. Es dry-run por defecto; `--go` para ejecutar.
|
||||
|
||||
### 7. Integrar
|
||||
|
||||
Cuando un secundario termina (rama pusheada + report verde):
|
||||
|
||||
1. **Revisa** su diff y su report. Si el report no trae evidencia ejecutable o falla la DoD,
|
||||
devuélvele trabajo (el humano puede saltar a su kitty, o tú le mandas otro prompt).
|
||||
2. **Mergea si procede** desde el **working tree principal** (ahí suele estar `master`
|
||||
checked-out): `git -C ~/fn_registry merge --no-ff <rama>` para apps con TBD, o el flujo que
|
||||
corresponda al sub-repo. Para funciones nuevas del registry padre, sus archivos viajan en la
|
||||
rama y el merge los lleva a master.
|
||||
3. **Informa al humano** y **resume el estado de la flota** en cada turno: quién terminó, quién
|
||||
sigue, qué se integró, qué falta.
|
||||
|
||||
### 8. kitty vs Agent tool — cuándo cada uno
|
||||
|
||||
- **kitty (este modo)**: trabajo **largo e interactivo** que el humano quiere **ver** y poder
|
||||
**retomar** — implementar una feature de horas, depurar en vivo, una sesión que evoluciona.
|
||||
- **Agent tool directo**: fan-out **acotado y no interactivo** — buscar en el codebase, crear
|
||||
una función con `fn-constructor`, auditar N apps con `fn-recopilador`. Más barato, sin
|
||||
terminal, sin supervisión humana. Para esto NO lances kitty: usa `Agent(...)` y ya.
|
||||
|
||||
Regla práctica: si el humano va a querer hablar con ello o mirarlo trabajar → kitty. Si es una
|
||||
sub-tarea que devuelve un resultado y se acabó → Agent tool.
|
||||
|
||||
## Reglas duras del modo
|
||||
|
||||
- **El orquestador no hace el trabajo pesado.** Descompone, lanza, sigue, integra. Si te
|
||||
encuentras escribiendo tú la feature, párate: ¿no debería ser un secundario?
|
||||
- **Cada secundario, su aislamiento.** Nunca lances dos secundarios sobre el mismo working tree
|
||||
sin worktrees/sub-repos/scopes disjuntos. Es la causa nº1 de commits perdidos.
|
||||
- **El prompt del secundario lleva SIEMPRE las reglas de aislamiento.** Un prompt sin "trabaja
|
||||
aquí, no toques aquello, commitea así" es un secundario que contaminará otro repo.
|
||||
- **Nunca `git add -A` en un secundario** salvo que su dir aislado sea exclusivamente suyo
|
||||
(worktree/sub-repo). En scope compartido, paths específicos.
|
||||
- **Nunca `pkill`/`killall claude`.** Kill por PID exacto o `reboot_all_claudes --exclude-current`.
|
||||
- **El humano habla contigo.** Tú resumes la flota; no le hagas perseguir 5 terminales.
|
||||
|
||||
## Anti-patrones
|
||||
|
||||
| Anti-patrón | Por qué es malo | En su lugar |
|
||||
|---|---|---|
|
||||
| `pkill claude` para parar la flota | Te mata a ti (el orquestador) también | Kill por PID exacto / `reboot_all_claudes --exclude-current` |
|
||||
| Dos secundarios en el mismo working tree | Comparten HEAD/índice → commits dispersos, ramas vacías | worktree / sub-repo / scope disjunto por secundario |
|
||||
| Prompt de secundario sin reglas de aislamiento | El secundario contamina el repo padre u otro worktree | El prompt fija dir, qué NO tocar, rama y cómo commitear |
|
||||
| `git add -A` en scope compartido | Arrastra cambios de otra sub-tarea al commit | `git add <paths-específicos>` |
|
||||
| Lanzar kitty para un fan-out trivial | Caro y sin supervisión que aporte | Agent tool directo (`fn-constructor`, `Explore`, …) |
|
||||
| Hacer tú la feature "porque es rápido" | Pierdes el sentido del modo; el humano no lo ve evolucionar | Descompón y lanza un secundario |
|
||||
| Lanzar sin `--dangerously-skip-permissions` | El secundario se atasca pidiendo permiso en cada Bash | Siempre `--dangerously-skip-permissions` (riesgo asumido) |
|
||||
| Mergear desde el dir del secundario | Master suele estar en el working tree principal; colisión de HEAD | Mergear desde `~/fn_registry` |
|
||||
|
||||
## Funciones del registry que usa este modo (grupo `orchestration`)
|
||||
|
||||
| Función | Para qué |
|
||||
|---|---|
|
||||
| `launch_claude_agent_kitty_bash_infra` | Lanzar un secundario en kitty con prompt autónomo + `--dangerously-skip-permissions` |
|
||||
| `list_claude_agents_bash_infra` | Listar la flota de Claudes vivos (PID, sessionId, cwd, status, kitty) para seguirla |
|
||||
| `reboot_all_claudes_bash_infra` | Reiniciar/parar la flota retomando sesiones; `--exclude-current` para no tocarte |
|
||||
|
||||
## Ejemplo end-to-end
|
||||
|
||||
Tarea grande: *"añade un endpoint `/api/health` al backend de la app `kanban` y, en paralelo,
|
||||
documenta el grupo de capacidad `deploy` en `docs/capabilities/deploy.md`"*. Dos piezas
|
||||
independientes: una toca el sub-repo `apps/kanban` (su propio `.git`), la otra toca el repo
|
||||
padre `fn_registry` (docs). Aislamiento natural distinto para cada una.
|
||||
|
||||
```bash
|
||||
# 1. Descomponer → 2 secundarios independientes:
|
||||
# A) health endpoint → sub-repo apps/kanban (aislamiento (a))
|
||||
# B) doc capability → worktree del padre (aislamiento (b))
|
||||
|
||||
# 2. Preparar aislamiento de B (worktree del padre; A ya está aislado por su sub-repo):
|
||||
git -C ~/fn_registry worktree add /tmp/orq_capdoc -b orq/cap-deploy master
|
||||
|
||||
# 3. Escribir los prompts autónomos (autocontenidos, con reglas de aislamiento):
|
||||
# /tmp/orq_health.md → "trabaja en apps/kanban (sub-repo propio), rama issue/health,
|
||||
# commits atómicos de tus paths, push al terminar, report en reports/. No toques el
|
||||
# repo padre. Reporta tu progreso en esta terminal."
|
||||
# /tmp/orq_capdoc.md → "trabaja SOLO en /tmp/orq_capdoc (worktree), rama orq/cap-deploy,
|
||||
# toca solo docs/capabilities/deploy.md, git add de ese path, push al terminar, report
|
||||
# en reports/. No toques ~/fn_registry. Reporta tu progreso en esta terminal."
|
||||
|
||||
# 4. Lanzar ambos secundarios (cada uno su kitty, su dir aislado):
|
||||
./fn run launch_claude_agent_kitty "kanban · health endpoint" \
|
||||
~/fn_registry/apps/kanban /tmp/orq_health.md
|
||||
./fn run launch_claude_agent_kitty "fn_registry · doc deploy" \
|
||||
/tmp/orq_capdoc /tmp/orq_capdoc.md
|
||||
|
||||
# 5. Seguir la flota (cada turno):
|
||||
./fn run list_claude_agents
|
||||
# → tabla con los 2 secundarios vivos (PID, cwd, sessionId, status) + tu SELF.
|
||||
# Lee /tmp/orq_*_kitty.log para el arranque; cuando terminen, lee sus reports/.
|
||||
|
||||
# 7. Integrar (desde el working tree principal):
|
||||
git -C ~/fn_registry/apps/kanban merge --no-ff issue/health # sub-repo de la app
|
||||
git -C ~/fn_registry merge --no-ff orq/cap-deploy # repo padre (la doc)
|
||||
git -C ~/fn_registry worktree remove /tmp/orq_capdoc # limpiar worktree
|
||||
|
||||
# Resumen al humano: A integrado (endpoint + test verde), B integrado (doc),
|
||||
# flota vacía. Tarea grande hecha.
|
||||
```
|
||||
|
||||
## Salida del modo
|
||||
|
||||
Cuando el humano escriba `salir orquestador` o `fin orquestador`, cierra con un resumen de la
|
||||
flota: secundarios lanzados, cuáles terminaron e integraste, cuáles siguen vivos (con su kitty
|
||||
para que el humano decida), y los reports generados. Si quedan secundarios vivos, recuérdale que
|
||||
`list_claude_agents` los lista y que para pararlos es kill por PID exacto, nunca `pkill`.
|
||||
|
||||
## Relación con otras reglas
|
||||
|
||||
- `.claude/rules/autonomous_loop.md` — `fn-orquestador` (Agent tool, sandbox no-interactivo). Es
|
||||
lo que este modo **no** es; tenlas claras separadas.
|
||||
- `.claude/rules/apps_subrepo.md` — apps/analyses/projects son sub-repos Gitea (`apps/*`
|
||||
gitignored): el aislamiento natural (opción (a)) y el gotcha de `git init` antes de limpiar un
|
||||
worktree con una app nueva dentro.
|
||||
- `.claude/rules/reports.md` + `.claude/rules/dod_quality.md` — qué entrega cada secundario:
|
||||
report con evidencia ejecutable + gaps.
|
||||
- `.claude/rules/delegation.md` + `.claude/rules/registry_calls.md` — los secundarios siguen
|
||||
registry-first y delegan a `fn-constructor` igual que tú.
|
||||
- Memorias: `lanzar-agentes-skip-permissions`, `multi-agent-git-race-same-repo`,
|
||||
`claude-session-pid-mapping`, `prefiere-kitty-terminal`.
|
||||
@@ -1,66 +0,0 @@
|
||||
---
|
||||
name: launch_claude_agent_kitty
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "launch_claude_agent_kitty(title: string, directory: string, prompt_file: string) -> string"
|
||||
description: "Lanza un Claude Code secundario interactivo y persistente en su propia terminal kitty, con un prompt autonomo inyectado desde un archivo y --dangerously-skip-permissions. Mecanica del modo orquestador: un Claude principal descompone una tarea y lanza N secundarios, cada uno en su kitty, que el humano ve y puede retomar. La ventana sobrevive al cierre de la terminal padre (setsid nohup ... disown) y deja una shell interactiva viva cuando el claude termina (exec zsh)."
|
||||
tags: [orchestration, agents, claude, kitty, agent, terminal, infra]
|
||||
params:
|
||||
- name: title
|
||||
desc: "Titulo de la ventana kitty. Ej: 'fn_registry · subtarea X'. Tambien se sanitiza (minusculas, no-alfanumerico -> '_') para derivar el slug del archivo de log."
|
||||
- name: directory
|
||||
desc: "Directorio de trabajo AISLADO donde arranca el claude secundario (worktree git, sub-repo, o dir cualquiera). Debe existir; si no -> error exit 2. Usar un dir aislado: dos claudes en el mismo working tree comparten HEAD y dispersan commits."
|
||||
- name: prompt_file
|
||||
desc: "Ruta a un archivo .md con el prompt autonomo a inyectar (ej. /tmp/orq_<slug>.md). Debe existir y ser legible; si no -> error exit 2."
|
||||
output: "Imprime en stdout el title, directory, prompt_file y la ruta del log (/tmp/orq_<slug>_kitty.log) donde se ve el arranque. Exit 0 = lanzamiento disparado; exit 2 = argumentos invalidos; exit 1 = kitty no instalado."
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/launch_claude_agent_kitty.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source bash/functions/infra/launch_claude_agent_kitty.sh
|
||||
|
||||
# El orquestador prepara un worktree aislado y un archivo de prompt...
|
||||
git worktree add /tmp/orq_docs_wt -b orq/docs
|
||||
cat > /tmp/orq_docs.md <<'PROMPT'
|
||||
Eres un agente secundario. Tu tarea: revisar y mejorar la documentacion del
|
||||
dominio infra del registry. Trabaja SOLO en este worktree. Reporta al terminar.
|
||||
PROMPT
|
||||
|
||||
# ...y lanza un claude secundario en su propia kitty:
|
||||
launch_claude_agent_kitty "fn_registry · docs" /tmp/orq_docs_wt /tmp/orq_docs.md
|
||||
# -> abre una ventana kitty titulada "fn_registry · docs", arranca claude con
|
||||
# el prompt inyectado, y deja /tmp/orq_fn_registry_docs_kitty.log con el arranque.
|
||||
```
|
||||
|
||||
O directo via `fn run`:
|
||||
|
||||
```bash
|
||||
./fn run launch_claude_agent_kitty "fn_registry · docs" /tmp/orq_docs_wt /tmp/orq_docs.md
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando el orquestador quiere lanzar un Claude secundario **interactivo** en su propia terminal kitty para una sub-tarea que el humano quiere **ver y poder retomar**. A diferencia del `Agent` tool (sub-agente no interactivo, headless, cuyo output vuelve al padre y no deja terminal abierta), aqui cada secundario corre en una ventana visible que persiste: el humano observa el progreso en vivo y, cuando el claude termina, la shell sigue ahi para continuar manualmente o relanzar.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **kitty debe estar instalado.** Si `command -v kitty` falla -> exit 1 con mensaje claro. No hay fallback a otra terminal.
|
||||
- **El `directory` debe ser AISLADO** (worktree git o sub-repo propio). Dos claudes apuntando al mismo working tree **comparten HEAD** y dispersan/cruzan los commits (memoria `multi-agent-git-race-same-repo`). El orquestador debe crear un worktree/clon por agente antes de llamar.
|
||||
- **`--dangerously-skip-permissions` corre sin pedir confirmacion** a ninguna accion (memoria `lanzar-agentes-skip-permissions`). Es a proposito para agentes autonomos desatendidos, pero es un riesgo asumido: el secundario puede tocar el sistema sin gates. No lanzar sobre directorios sensibles.
|
||||
- **El log de `/tmp/orq_<slug>_kitty.log` es donde se ve el arranque** (errores de kitty/claude al iniciar). El `<slug>` deriva del `title` sanitizado; titulos distintos que colapsen al mismo slug sobrescriben el mismo log.
|
||||
- **El PID reportado no es el de kitty.** Con `setsid` el `$!` es el del proceso setsid, no el de la ventana; por eso la funcion reporta el log en vez de un PID. Para encontrar la ventana despues: `pgrep -af kitty | grep <title>`.
|
||||
- **El prompt se inyecta con `"$(cat <prompt_file>)"` evaluado DENTRO de la kitty.** Si editas el `prompt_file` despues de lanzar pero antes de que la kitty arranque, el claude vera la version editada (se lee en el momento del arranque, no del lanzamiento).
|
||||
@@ -1,135 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# launch_claude_agent_kitty — Lanza un Claude Code secundario interactivo y
|
||||
# persistente en su propia terminal kitty, con un prompt autonomo inyectado
|
||||
# desde un archivo. Es la mecanica de lanzamiento del "modo orquestador": un
|
||||
# Claude principal descompone una tarea y lanza N secundarios, cada uno en su
|
||||
# kitty, que el humano ve y puede retomar.
|
||||
#
|
||||
# Mecanismo:
|
||||
# - setsid nohup kitty ... & disown -> la ventana sobrevive al cierre de la
|
||||
# terminal padre (igual que reboot_all_claudes con setsid).
|
||||
# - zsh -ic 'claude ...; exec zsh' -> al terminar el claude queda una shell
|
||||
# interactiva viva para que el humano siga en esa terminal.
|
||||
# - --dangerously-skip-permissions -> agente autonomo desatendido (sin
|
||||
# confirmaciones). Riesgo asumido a proposito.
|
||||
# - El prompt se inyecta con "$(cat <prompt_file>)" para no expandir nada en
|
||||
# el shell del orquestador.
|
||||
# - Log de arranque en /tmp/orq_<slug>_kitty.log, donde <slug> deriva del
|
||||
# title (minusculas, no-alfanumerico -> '_').
|
||||
set -euo pipefail
|
||||
IFS=$' \t\n'
|
||||
|
||||
launch_claude_agent_kitty() {
|
||||
# -----------------------------------------------------------------------
|
||||
# Ayuda / sin argumentos.
|
||||
# -----------------------------------------------------------------------
|
||||
if [[ $# -eq 0 || "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||
cat <<'USAGE'
|
||||
Uso: launch_claude_agent_kitty <title> <directory> <prompt_file>
|
||||
|
||||
Lanza un Claude Code secundario interactivo y persistente en su propia
|
||||
terminal kitty, con el prompt del archivo <prompt_file> inyectado y
|
||||
--dangerously-skip-permissions (agente autonomo desatendido).
|
||||
|
||||
Argumentos (los 3 obligatorios):
|
||||
title Titulo de la ventana kitty. Ej: "fn_registry · subtarea X".
|
||||
directory Directorio de trabajo AISLADO donde arranca el claude
|
||||
secundario (worktree git, sub-repo, o dir cualquiera). Debe
|
||||
existir. Usa un dir aislado: dos claudes en el mismo working
|
||||
tree comparten HEAD y dispersan commits.
|
||||
prompt_file Ruta a un archivo .md con el prompt autonomo a inyectar.
|
||||
Debe existir y ser legible.
|
||||
|
||||
Ejemplo:
|
||||
launch_claude_agent_kitty "fn_registry · docs" /tmp/orq_docs_wt /tmp/orq_docs.md
|
||||
|
||||
El log de arranque va a /tmp/orq_<slug>_kitty.log (slug derivado del title).
|
||||
USAGE
|
||||
return 0
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Validacion de argumentos.
|
||||
# -----------------------------------------------------------------------
|
||||
if [[ $# -ne 3 ]]; then
|
||||
echo "launch_claude_agent_kitty: se requieren 3 argumentos <title> <directory> <prompt_file> (recibidos: $#). Usa -h." >&2
|
||||
return 2
|
||||
fi
|
||||
|
||||
local title="$1"
|
||||
local directory="$2"
|
||||
local prompt_file="$3"
|
||||
|
||||
if [[ -z "$title" ]]; then
|
||||
echo "launch_claude_agent_kitty: <title> no puede estar vacio." >&2
|
||||
return 2
|
||||
fi
|
||||
|
||||
if [[ ! -d "$directory" ]]; then
|
||||
echo "launch_claude_agent_kitty: el directorio de trabajo no existe: '$directory'." >&2
|
||||
return 2
|
||||
fi
|
||||
|
||||
if [[ ! -f "$prompt_file" ]]; then
|
||||
echo "launch_claude_agent_kitty: el prompt_file no existe: '$prompt_file'." >&2
|
||||
return 2
|
||||
fi
|
||||
|
||||
if [[ ! -r "$prompt_file" ]]; then
|
||||
echo "launch_claude_agent_kitty: el prompt_file no es legible: '$prompt_file'." >&2
|
||||
return 2
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Comprobar que kitty esta instalado.
|
||||
# -----------------------------------------------------------------------
|
||||
if ! command -v kitty >/dev/null 2>&1; then
|
||||
echo "launch_claude_agent_kitty: 'kitty' no esta instalado o no esta en el PATH." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Derivar el slug del title para el nombre del log.
|
||||
# minusculas, todo no-alfanumerico -> '_', colapsar/recortar '_'.
|
||||
# -----------------------------------------------------------------------
|
||||
local slug
|
||||
slug="$(printf '%s' "$title" \
|
||||
| tr '[:upper:]' '[:lower:]' \
|
||||
| tr -c 'a-z0-9' '_' \
|
||||
| sed -E 's/_+/_/g; s/^_//; s/_$//')"
|
||||
[[ -z "$slug" ]] && slug="agent"
|
||||
|
||||
local log="/tmp/orq_${slug}_kitty.log"
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Lanzar la kitty detached. El prompt se inyecta con "$(cat <prompt_file>)"
|
||||
# ya escapado para que se evalue DENTRO de la kitty, no aqui.
|
||||
# exec zsh deja una shell viva cuando el claude termina.
|
||||
# -----------------------------------------------------------------------
|
||||
local inner
|
||||
inner="claude --dangerously-skip-permissions \"\$(cat $(printf '%q' "$prompt_file"))\"; exec zsh"
|
||||
|
||||
setsid nohup kitty \
|
||||
--title "$title" \
|
||||
--directory "$directory" \
|
||||
zsh -ic "$inner" \
|
||||
>"$log" 2>&1 &
|
||||
disown 2>/dev/null || true
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Reportar. Con setsid el $! es el PID de setsid, no el de kitty; basta
|
||||
# con confirmar el lanzamiento y apuntar al log donde se ve el arranque.
|
||||
# -----------------------------------------------------------------------
|
||||
echo "launch_claude_agent_kitty: claude secundario lanzado."
|
||||
echo " title: $title"
|
||||
echo " directory: $directory"
|
||||
echo " prompt_file: $prompt_file"
|
||||
echo " log: $log"
|
||||
echo " (sigue el arranque con: tail -f $(printf '%q' "$log"))"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Permitir ejecutar el archivo directamente (no solo como funcion sourced).
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
launch_claude_agent_kitty "$@"
|
||||
fi
|
||||
@@ -1,55 +0,0 @@
|
||||
---
|
||||
name: list_claude_agents
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "list_claude_agents([--json] [--exclude-current] [-h|--help])"
|
||||
description: "Lista todas las instancias de Claude Code VIVAS cruzando pgrep -x claude con los archivos de estado por proceso ~/.claude/sessions/<PID>.json. Para cada claude vivo y validado devuelve PID, status (idle/busy), etime (tiempo de vida), KITTY_PID de su ventana kitty, sessionId y cwd. Es la herramienta de seguimiento de la flota del modo orquestador: el Claude principal ve que agentes secundarios siguen vivos, en que directorio trabajan y su sessionId para retomarlos con claude --resume."
|
||||
tags: [orchestration, claude, session, fleet, kitty, infra, terminal-capture]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: "--json"
|
||||
desc: "Imprime un array JSON (un objeto por agente con pid, session_id, cwd, status, etime, kitty_pid, self) en vez de la tabla legible. Pensado para que el agente parsee y decida cual retomar/parar."
|
||||
- name: "--exclude-current"
|
||||
desc: "Omite la propia sesion del listado. Detecta el claude propio subiendo por la cadena de ancestros de $$ hasta hallar un proceso con comm=claude. Sin esta opcion, la sesion actual se marca (columna SELF en tabla / self=true en JSON)."
|
||||
- name: "-h|--help"
|
||||
desc: "Muestra el uso y termina con exit 0."
|
||||
output: "En modo tabla: una fila por claude vivo y validado con columnas PID, STATUS, ETIME, KITTY, SELF, SESSION_ID, CWD. En modo --json: array JSON con pid, session_id, cwd, status, etime, kitty_pid (null si no corre en kitty) y self. Si no hay claudes vivos imprime aviso (tabla) o [] (json) y exit 0. Exit 0 normal; exit 2 si flag invalido."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/list_claude_agents.sh"
|
||||
notes: "Mecanismo (Claude Code 2.1.x sobre Linux + kitty): pgrep -x claude -> PIDs vivos; ~/.claude/sessions/<PID>.json -> sessionId/cwd/status/procStart (parseado con python3); validacion en tres capas: kill -0 <PID> exito, el JSON existe, y anti-PID-reciclado comparando procStart del JSON con el campo 22 de /proc/<PID>/stat (si difieren el JSON es huerfano de un PID reusado y se omite). KITTY_PID se saca del environ del proceso (tr '\\0' '\\n' < /proc/<PID>/environ | sed -n 's/^KITTY_PID=//p'). etime via ps -o etime= -p <PID>. Reusa la misma logica de descubrimiento y validacion que reboot_all_claudes_bash_infra. El codigo JSON va en python3 -c con los datos por stdin TSV (no heredoc) para no colisionar el stdin del pipe."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Tabla legible de la flota de Claudes vivos (PID, status, etime, kitty, sessionId, cwd).
|
||||
./fn run list_claude_agents
|
||||
|
||||
# Array JSON para parsear (decidir cual retomar con claude --resume <session_id>).
|
||||
./fn run list_claude_agents --json
|
||||
|
||||
# Omitir la propia sesion (ver solo los agentes secundarios).
|
||||
./fn run list_claude_agents --exclude-current
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando el orquestador necesita ver la flota de Claudes secundarios vivos (PID, cwd, sessionId, status) para seguir su progreso o decidir cual retomar/parar. Lanzala al inicio de un ciclo de seguimiento para saber que agentes siguen activos y en que directorio trabaja cada uno; usa `--json` cuando vayas a programar la decision (filtrar por `status`, extraer `session_id` para un `claude --resume`).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Requiere Claude Code >= 2.1.x.** Depende de que cada sesion escriba `~/.claude/sessions/<PID>.json` con los campos `sessionId`, `cwd`, `status`, `procStart`. Si una version futura cambia el formato, la funcion deja de mapear PID -> sessionId y omitira las sesiones.
|
||||
- **Un JSON puede ser huerfano por PID reciclado.** El sistema operativo reusa PIDs; un `<PID>.json` viejo puede apuntar a un proceso `claude` distinto. Por eso se valida `procStart` del JSON contra el campo 22 de `/proc/<PID>/stat`; si no coincide la entrada se descarta. Sin esa validacion se reportarian agentes fantasma.
|
||||
- **El titulo exacto de la ventana kitty no se recupera sin `kitty @`.** Se reporta el `KITTY_PID` (suficiente para identificar la ventana); mapearlo al titulo requeriria `kitty @ ls`, que solo funciona si el control remoto de kitty esta habilitado. KISS: se omite por defecto. Un claude que corra fuera de kitty (terminal integrado de un editor, etc.) sale con `KITTY` vacio `(none)` / `kitty_pid: null`.
|
||||
- **Solo ve procesos del usuario actual.** `pgrep -x claude` y la lectura de `/proc/<PID>/{environ,stat}` solo cubren los claudes del propio usuario; no lista sesiones de otros usuarios del sistema.
|
||||
- **`status` refleja el ultimo estado guardado en el JSON**, no necesariamente el instante exacto de la consulta (Claude actualiza el archivo al cambiar de estado). Pueden aparecer valores como `idle`, `busy` o `waiting`.
|
||||
@@ -1,265 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# list_claude_agents — Lista todas las instancias de Claude Code VIVAS cruzando
|
||||
# pgrep -x claude con los archivos de estado por proceso ~/.claude/sessions/<PID>.json.
|
||||
# Para cada claude vivo y validado reporta: PID, status (idle/busy), etime (tiempo de
|
||||
# vida del proceso), KITTY_PID de la ventana kitty si corre en una, sessionId y cwd.
|
||||
# Es la herramienta de "seguimiento de la flota" del modo orquestador: el Claude
|
||||
# principal la usa para ver que agentes secundarios siguen vivos, en que directorio
|
||||
# trabajan y su sessionId (para poder retomarlos con claude --resume <sessionId>).
|
||||
#
|
||||
# Mecanismo (Claude Code 2.1.x sobre Linux + kitty):
|
||||
# - pgrep -x claude -> PIDs de las sesiones interactivas vivas.
|
||||
# - ~/.claude/sessions/<PID>.json -> mapea PID a {sessionId, cwd, status, procStart}.
|
||||
# - Anti-PID-reciclado: procStart del JSON debe coincidir con el campo 22 de
|
||||
# /proc/<PID>/stat; ademas kill -0 <PID> debe tener exito y el JSON debe existir.
|
||||
# - KITTY_PID del environ del proceso -> ventana kitty (titulo exacto requeriria
|
||||
# 'kitty @ ls'; aqui se reporta el KITTY_PID, suficiente para identificarla).
|
||||
# - etime via ps -o etime= -p <PID>.
|
||||
set -euo pipefail
|
||||
IFS=$' \t\n'
|
||||
|
||||
list_claude_agents() {
|
||||
local output="table" # table | json
|
||||
local exclude_current=0
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Parseo de argumentos
|
||||
# -----------------------------------------------------------------------
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--json)
|
||||
output="json"
|
||||
;;
|
||||
--exclude-current)
|
||||
exclude_current=1
|
||||
;;
|
||||
-h|--help)
|
||||
cat <<'USAGE'
|
||||
Uso: list_claude_agents [--json] [--exclude-current]
|
||||
|
||||
Lista las instancias de Claude Code vivas y validas, una fila por agente, con su
|
||||
PID, status, etime (tiempo de vida), KITTY_PID, sessionId y cwd. Pensada para el
|
||||
modo orquestador: ver la flota de Claudes secundarios y su sessionId para retomar
|
||||
(claude --resume <sessionId>) o decidir cual parar.
|
||||
|
||||
Opciones:
|
||||
--json Imprime un array JSON (pid, session_id, cwd, status, etime,
|
||||
kitty_pid) en vez de la tabla. Util para parsear.
|
||||
--exclude-current Omite la propia sesion (sube por la cadena de ancestros de
|
||||
$$ hasta hallar un proceso con comm=claude). Sin esta opcion,
|
||||
la sesion actual se marca con self=true / SELF en la tabla.
|
||||
-h, --help Muestra esta ayuda.
|
||||
|
||||
Ejemplos:
|
||||
list_claude_agents
|
||||
list_claude_agents --json
|
||||
list_claude_agents --exclude-current
|
||||
USAGE
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
echo "list_claude_agents: opcion desconocida: '$1' (usa -h)" >&2
|
||||
return 2
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Detectar el PID de la sesion actual subiendo por la cadena de ancestros
|
||||
# hasta encontrar un proceso cuyo comm sea exactamente "claude".
|
||||
# Se usa tanto para --exclude-current como para marcar la fila propia.
|
||||
# -----------------------------------------------------------------------
|
||||
local current_claude_pid=""
|
||||
local walk="$$"
|
||||
local guard=0
|
||||
while [[ -n "$walk" && "$walk" != "0" && "$walk" != "1" ]]; do
|
||||
local comm=""
|
||||
comm="$(cat "/proc/$walk/comm" 2>/dev/null || true)"
|
||||
if [[ "$comm" == "claude" ]]; then
|
||||
current_claude_pid="$walk"
|
||||
break
|
||||
fi
|
||||
# campo 4 de /proc/<pid>/stat es el PPID
|
||||
walk="$(awk '{print $4}' "/proc/$walk/stat" 2>/dev/null || true)"
|
||||
guard=$((guard + 1))
|
||||
[[ "$guard" -gt 64 ]] && break
|
||||
done
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Recolectar las sesiones vivas y validarlas.
|
||||
# -----------------------------------------------------------------------
|
||||
local sessions_dir="$HOME/.claude/sessions"
|
||||
local pids=""
|
||||
pids="$(pgrep -x claude 2>/dev/null || true)"
|
||||
|
||||
if [[ -z "$pids" ]]; then
|
||||
if [[ "$output" == "json" ]]; then
|
||||
echo "[]"
|
||||
else
|
||||
echo "list_claude_agents: no hay sesiones de Claude Code vivas (pgrep -x claude vacio)."
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Arrays paralelos con la flota validada.
|
||||
local -a a_pid a_status a_etime a_kitty a_sid a_cwd a_self
|
||||
|
||||
local pid
|
||||
for pid in $pids; do
|
||||
# Validacion 1: el proceso debe seguir vivo.
|
||||
if ! kill -0 "$pid" 2>/dev/null; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Validacion 2: debe existir su JSON de sesion.
|
||||
local json="$sessions_dir/$pid.json"
|
||||
if [[ ! -f "$json" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Parsear el JSON con python3 (campos sessionId, cwd, status, procStart).
|
||||
# Salida: lineas "clave=valor" en orden fijo.
|
||||
local parsed=""
|
||||
parsed="$(python3 - "$json" <<'PY' 2>/dev/null || true
|
||||
import json, sys
|
||||
try:
|
||||
with open(sys.argv[1]) as fh:
|
||||
d = json.load(fh)
|
||||
except Exception:
|
||||
sys.exit(0)
|
||||
print("sessionId=" + str(d.get("sessionId", "")))
|
||||
print("cwd=" + str(d.get("cwd", "")))
|
||||
print("status=" + str(d.get("status", "")))
|
||||
print("procStart=" + str(d.get("procStart", "")))
|
||||
PY
|
||||
)"
|
||||
[[ -z "$parsed" ]] && continue
|
||||
|
||||
local sid cwd status proc_start_json
|
||||
sid="$(printf '%s\n' "$parsed" | sed -n 's/^sessionId=//p')"
|
||||
cwd="$(printf '%s\n' "$parsed" | sed -n 's/^cwd=//p')"
|
||||
status="$(printf '%s\n' "$parsed" | sed -n 's/^status=//p')"
|
||||
proc_start_json="$(printf '%s\n' "$parsed" | sed -n 's/^procStart=//p')"
|
||||
|
||||
[[ -z "$sid" ]] && continue
|
||||
|
||||
# Validacion 3 (anti-PID-reciclado): procStart del JSON debe coincidir
|
||||
# con el campo 22 de /proc/<PID>/stat.
|
||||
local proc_start_real=""
|
||||
proc_start_real="$(awk '{print $22}' "/proc/$pid/stat" 2>/dev/null || true)"
|
||||
if [[ -n "$proc_start_json" && "$proc_start_json" != "$proc_start_real" ]]; then
|
||||
# JSON huerfano de un PID reciclado: omitir.
|
||||
continue
|
||||
fi
|
||||
|
||||
# Omitir la propia sesion si se pidio --exclude-current.
|
||||
if [[ "$exclude_current" -eq 1 && -n "$current_claude_pid" && "$pid" == "$current_claude_pid" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# KITTY_PID de la ventana kitty (vacio si claude no corre en kitty).
|
||||
local kitty_pid=""
|
||||
kitty_pid="$(tr '\0' '\n' < "/proc/$pid/environ" 2>/dev/null | sed -n 's/^KITTY_PID=//p' | head -n1)"
|
||||
|
||||
# etime: tiempo transcurrido desde que arranco el proceso.
|
||||
local etime=""
|
||||
etime="$(ps -o etime= -p "$pid" 2>/dev/null | tr -d ' ' || true)"
|
||||
|
||||
# Marca de sesion propia (solo relevante cuando NO se excluye).
|
||||
local self="false"
|
||||
if [[ -n "$current_claude_pid" && "$pid" == "$current_claude_pid" ]]; then
|
||||
self="true"
|
||||
fi
|
||||
|
||||
a_pid+=("$pid")
|
||||
a_status+=("${status:-?}")
|
||||
a_etime+=("${etime:-?}")
|
||||
a_kitty+=("${kitty_pid:-}")
|
||||
a_sid+=("$sid")
|
||||
a_cwd+=("${cwd:-?}")
|
||||
a_self+=("$self")
|
||||
done
|
||||
|
||||
local total="${#a_pid[@]}"
|
||||
if [[ "$total" -eq 0 ]]; then
|
||||
if [[ "$output" == "json" ]]; then
|
||||
echo "[]"
|
||||
else
|
||||
echo "list_claude_agents: ninguna sesion valida encontrada (PIDs huerfanos, reciclados, o sin JSON)."
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Salida JSON.
|
||||
# -----------------------------------------------------------------------
|
||||
if [[ "$output" == "json" ]]; then
|
||||
# Delegar el escaping correcto de strings (cwd con espacios, etc.) a python3.
|
||||
# El codigo python va en -c y los datos por stdin (TSV), para no colisionar
|
||||
# el heredoc con el stdin del pipe.
|
||||
local i
|
||||
{
|
||||
for ((i = 0; i < total; i++)); do
|
||||
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
|
||||
"${a_pid[$i]}" \
|
||||
"${a_sid[$i]}" \
|
||||
"${a_cwd[$i]}" \
|
||||
"${a_status[$i]}" \
|
||||
"${a_etime[$i]}" \
|
||||
"${a_kitty[$i]}" \
|
||||
"${a_self[$i]}"
|
||||
done
|
||||
} | python3 -c '
|
||||
import json, sys
|
||||
out = []
|
||||
for line in sys.stdin:
|
||||
line = line.rstrip("\n")
|
||||
if not line:
|
||||
continue
|
||||
pid, sid, cwd, status, etime, kitty, self_ = line.split("\t")
|
||||
out.append({
|
||||
"pid": int(pid) if pid.isdigit() else pid,
|
||||
"session_id": sid,
|
||||
"cwd": cwd,
|
||||
"status": status,
|
||||
"etime": etime,
|
||||
"kitty_pid": (int(kitty) if kitty.isdigit() else (kitty or None)),
|
||||
"self": (self_ == "true"),
|
||||
})
|
||||
print(json.dumps(out, indent=2))
|
||||
'
|
||||
return 0
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Salida tabla legible.
|
||||
# -----------------------------------------------------------------------
|
||||
echo "list_claude_agents — claudes vivos: ${total}"
|
||||
echo
|
||||
printf '%-8s %-7s %-12s %-9s %-6s %-38s %s\n' \
|
||||
"PID" "STATUS" "ETIME" "KITTY" "SELF" "SESSION_ID" "CWD"
|
||||
printf '%-8s %-7s %-12s %-9s %-6s %-38s %s\n' \
|
||||
"--------" "-------" "------------" "---------" "------" \
|
||||
"--------------------------------------" "---"
|
||||
|
||||
local i
|
||||
for ((i = 0; i < total; i++)); do
|
||||
local self_mark=""
|
||||
[[ "${a_self[$i]}" == "true" ]] && self_mark="SELF"
|
||||
printf '%-8s %-7s %-12s %-9s %-6s %-38s %s\n' \
|
||||
"${a_pid[$i]}" \
|
||||
"${a_status[$i]}" \
|
||||
"${a_etime[$i]}" \
|
||||
"${a_kitty[$i]:-(none)}" \
|
||||
"${self_mark:--}" \
|
||||
"${a_sid[$i]}" \
|
||||
"${a_cwd[$i]}"
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
# Permitir ejecutar el archivo directamente (no solo como funcion sourced).
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
list_claude_agents "$@"
|
||||
fi
|
||||
@@ -0,0 +1,150 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// natsVarz refleja los campos relevantes de la respuesta JSON del endpoint
|
||||
// /varz del monitoring HTTP embebido de un nats-server (puerto 8222, loopback).
|
||||
// Solo se mapean los campos que producen series; el resto se ignora.
|
||||
type natsVarz struct {
|
||||
InMsgs int64 `json:"in_msgs"`
|
||||
OutMsgs int64 `json:"out_msgs"`
|
||||
InBytes int64 `json:"in_bytes"`
|
||||
OutBytes int64 `json:"out_bytes"`
|
||||
Connections int `json:"connections"`
|
||||
SlowConsumers int `json:"slow_consumers"`
|
||||
Subscriptions int `json:"subscriptions"`
|
||||
Mem int64 `json:"mem"`
|
||||
Start string `json:"start"`
|
||||
}
|
||||
|
||||
// natsConnz refleja los campos relevantes de /connz.
|
||||
type natsConnz struct {
|
||||
NumConnections int `json:"num_connections"`
|
||||
}
|
||||
|
||||
// natsStreamDetail refleja un stream dentro de account_details[].stream_detail[].
|
||||
type natsStreamDetail struct {
|
||||
Name string `json:"name"`
|
||||
Cluster struct {
|
||||
Leader string `json:"leader"`
|
||||
} `json:"cluster"`
|
||||
State struct {
|
||||
Messages int64 `json:"messages"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
} `json:"state"`
|
||||
}
|
||||
|
||||
// natsJsz refleja los campos relevantes de /jsz?streams=1.
|
||||
type natsJsz struct {
|
||||
Streams int64 `json:"streams"`
|
||||
Messages int64 `json:"messages"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
Memory int64 `json:"memory"`
|
||||
Storage int64 `json:"storage"`
|
||||
AccountDetails []struct {
|
||||
StreamDetail []natsStreamDetail `json:"stream_detail"`
|
||||
} `json:"account_details"`
|
||||
}
|
||||
|
||||
// ParseNatsMonitor convierte las respuestas JSON del endpoint de monitoring HTTP
|
||||
// embebido de un nats-server (puerto 8222, loopback) en una serie de PromSample
|
||||
// lista para empujar a VictoriaMetrics. Es la hermana de ParseUnibusHealth para
|
||||
// las métricas server-level de NATS/JetStream (msgs/s, conexiones, KV bucket
|
||||
// msgs, RAFT leader por stream, memoria). La consume el unibus_exporter de
|
||||
// fleet_monitoring en modo scraper local por nodo.
|
||||
//
|
||||
// node es el nombre lógico del nodo (p.ej. "magnus"); se adjunta a CADA serie
|
||||
// como las labels "node" e "instance" para distinguir los nodos cuando un único
|
||||
// exporter scrapea varios.
|
||||
//
|
||||
// varz, connz y jsz son los cuerpos crudos de GET /varz, GET /connz y
|
||||
// GET /jsz?streams=1 respectivamente:
|
||||
// - varz es el core: si NO parsea como JSON válido devuelve (nil, error).
|
||||
// - connz y jsz son best-effort: si vienen vacíos o no parsean, sus series se
|
||||
// omiten sin abortar (no error), para que el scraper resista que un endpoint
|
||||
// falle. nats_connections cae a varz.connections cuando connz no parsea.
|
||||
func ParseNatsMonitor(node string, varz, connz, jsz []byte) ([]PromSample, error) {
|
||||
var v natsVarz
|
||||
if err := json.Unmarshal(varz, &v); err != nil {
|
||||
return nil, fmt.Errorf("parse nats varz for node %q: %w", node, err)
|
||||
}
|
||||
|
||||
// mk construye un PromSample con las labels base {node, instance} más, de
|
||||
// forma opcional, labels extra (clave/valor alternados). Las labels base no
|
||||
// se pueden sobreescribir desde extra.
|
||||
mk := func(name string, val float64, extra ...string) PromSample {
|
||||
labels := map[string]string{"node": node, "instance": node}
|
||||
for i := 0; i+1 < len(extra); i += 2 {
|
||||
labels[extra[i]] = extra[i+1]
|
||||
}
|
||||
return PromSample{Name: name, Labels: labels, Value: val}
|
||||
}
|
||||
|
||||
out := []PromSample{
|
||||
mk("nats_msgs_in_total", float64(v.InMsgs)),
|
||||
mk("nats_msgs_out_total", float64(v.OutMsgs)),
|
||||
mk("nats_bytes_in_total", float64(v.InBytes)),
|
||||
mk("nats_bytes_out_total", float64(v.OutBytes)),
|
||||
}
|
||||
|
||||
// nats_connections: prefiere connz.num_connections; si connz no parsea, cae
|
||||
// a varz.connections para no perder la serie.
|
||||
connections := float64(v.Connections)
|
||||
if len(connz) > 0 {
|
||||
var c natsConnz
|
||||
if err := json.Unmarshal(connz, &c); err == nil {
|
||||
connections = float64(c.NumConnections)
|
||||
}
|
||||
}
|
||||
out = append(out,
|
||||
mk("nats_connections", connections),
|
||||
mk("nats_slow_consumers", float64(v.SlowConsumers)),
|
||||
mk("nats_mem_bytes", float64(v.Mem)),
|
||||
mk("nats_subscriptions", float64(v.Subscriptions)),
|
||||
)
|
||||
|
||||
// nats_server_start_seconds: epoch (segundos Unix) del campo start (RFC3339).
|
||||
// Proxy de reinicios del nats-server: un cambio de este valor = el server
|
||||
// reinició. Si el parse de la fecha falla, se omite la serie (no se aborta).
|
||||
if t, err := time.Parse(time.RFC3339, v.Start); err == nil {
|
||||
out = append(out, mk("nats_server_start_seconds", float64(t.Unix())))
|
||||
}
|
||||
|
||||
// jsz es best-effort: si vacío o inválido, se omiten todas sus series.
|
||||
if len(jsz) > 0 {
|
||||
var j natsJsz
|
||||
if err := json.Unmarshal(jsz, &j); err == nil {
|
||||
out = append(out,
|
||||
mk("nats_jetstream_streams", float64(j.Streams)),
|
||||
mk("nats_jetstream_messages", float64(j.Messages)),
|
||||
mk("nats_jetstream_bytes", float64(j.Bytes)),
|
||||
mk("nats_jetstream_memory_bytes", float64(j.Memory)),
|
||||
mk("nats_jetstream_storage_bytes", float64(j.Storage)),
|
||||
)
|
||||
for _, acc := range j.AccountDetails {
|
||||
for _, sd := range acc.StreamDetail {
|
||||
out = append(out,
|
||||
mk("nats_stream_messages", float64(sd.State.Messages), "stream", sd.Name),
|
||||
mk("nats_stream_bytes", float64(sd.State.Bytes), "stream", sd.Name),
|
||||
)
|
||||
leader := 0.0
|
||||
if sd.Cluster.Leader == node {
|
||||
leader = 1
|
||||
}
|
||||
out = append(out, mk("nats_jetstream_raft_leader", leader, "stream", sd.Name))
|
||||
|
||||
if bucket, ok := strings.CutPrefix(sd.Name, "KV_"); ok {
|
||||
out = append(out, mk("kv_bucket_msgs", float64(sd.State.Messages), "bucket", bucket))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
---
|
||||
name: parse_nats_monitor
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func ParseNatsMonitor(node string, varz, connz, jsz []byte) ([]PromSample, error)"
|
||||
description: "Convierte las respuestas JSON del endpoint de monitoring HTTP embebido de un nats-server (puerto 8222, loopback) en una serie de PromSample lista para empujar a VictoriaMetrics. Hermana de ParseUnibusHealth pero para las métricas server-level de NATS/JetStream: msgs/s, bytes, conexiones, slow consumers, memoria RSS, start epoch (proxy de reinicios), streams/messages/bytes/memory/storage de JetStream, y por stream nats_stream_messages/bytes, nats_jetstream_raft_leader y kv_bucket_msgs para los buckets KV_. Adjunta labels node e instance a cada serie. varz es el core (error si no parsea); connz y jsz son best-effort (se omiten sin abortar). La consume el unibus_exporter de fleet_monitoring como scraper local por nodo."
|
||||
tags: [prometheus, metrics, nats, jetstream, monitoring, varz, connz, jsz, kv, raft, fleet-metrics, infra]
|
||||
uses_functions: []
|
||||
uses_types: ["PromSample_go_infra"]
|
||||
returns: []
|
||||
returns_optional: true
|
||||
error_type: "error_go_core"
|
||||
imports: ["encoding/json", "fmt", "strings", "time"]
|
||||
params:
|
||||
- name: node
|
||||
desc: "nombre lógico del nodo (p.ej. \"magnus\"); se adjunta como labels node e instance a CADA serie y se compara con cluster.leader de cada stream para nats_jetstream_raft_leader"
|
||||
- name: varz
|
||||
desc: "cuerpo JSON crudo de GET http://127.0.0.1:8222/varz; core de la función (in_msgs, out_msgs, in_bytes, out_bytes, connections, slow_consumers, subscriptions, mem, start). Si no parsea, la función devuelve error"
|
||||
- name: connz
|
||||
desc: "cuerpo JSON crudo de GET http://127.0.0.1:8222/connz; best-effort (num_connections). Si vacío o inválido, nats_connections cae a varz.connections sin abortar"
|
||||
- name: jsz
|
||||
desc: "cuerpo JSON crudo de GET http://127.0.0.1:8222/jsz?streams=1; best-effort (streams, messages, bytes, memory, storage y account_details[].stream_detail[]). Si vacío o inválido, se omiten sus series sin abortar. Necesita ?streams=1 para traer stream_detail"
|
||||
output: "slice de PromSample con labels base {node,instance}: nats_msgs_in/out_total, nats_bytes_in/out_total, nats_connections, nats_slow_consumers, nats_mem_bytes, nats_subscriptions, nats_server_start_seconds (omitida si start no parsea), nats_jetstream_streams/messages/bytes/memory_bytes/storage_bytes; y por stream nats_stream_messages{stream}, nats_stream_bytes{stream}, nats_jetstream_raft_leader{stream} (1 si cluster.leader==node) y, para streams KV_, kv_bucket_msgs{bucket} con el prefijo KV_ recortado. Error solo si varz no es JSON válido."
|
||||
tested: true
|
||||
test_file_path: "functions/infra/parse_nats_monitor_test.go"
|
||||
tests:
|
||||
- "TestParseNatsMonitorGolden"
|
||||
- "TestParseNatsMonitorEmptyJsz"
|
||||
- "TestParseNatsMonitorInvalidConnz"
|
||||
- "TestParseNatsMonitorInvalidVarz"
|
||||
---
|
||||
|
||||
# parse_nats_monitor
|
||||
|
||||
Función de transformación (clasificada `impure` porque devuelve `error` al fallar el
|
||||
unmarshal del core; no hace I/O ni red por sí misma) que traduce las métricas
|
||||
server-level de un **nats-server** a series Prometheus. Es la hermana de
|
||||
`parse_unibus_health_go_infra`: aquella lee el `/healthz` de `membershipd` (posture),
|
||||
esta lee el monitoring embebido de NATS (puerto 8222) para las métricas profundas que
|
||||
`/healthz` no expone: msgs/s, conexiones, RAFT leader por stream, memoria, KV buckets.
|
||||
|
||||
Pertenece al grupo de capacidad `fleet-metrics`: se compone con
|
||||
`format_prom_exposition_go_infra` (serializar) y `push_prom_remote_go_infra` (empujar a
|
||||
VictoriaMetrics). La consume el `unibus_exporter` de `fleet_monitoring` en modo scraper
|
||||
local por nodo, que hace los tres GET y le pasa los cuerpos crudos.
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"fn-registry/functions/infra"
|
||||
)
|
||||
|
||||
func get(url string) []byte {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return nil // best-effort: connz/jsz pueden faltar
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return b
|
||||
}
|
||||
|
||||
func main() {
|
||||
base := "http://127.0.0.1:8222"
|
||||
varz := get(base + "/varz")
|
||||
connz := get(base + "/connz")
|
||||
jsz := get(base + "/jsz?streams=1")
|
||||
|
||||
samples, err := infra.ParseNatsMonitor("magnus", varz, connz, jsz)
|
||||
if err != nil {
|
||||
panic(err) // varz es el core: sin él no hay métricas
|
||||
}
|
||||
fmt.Print(infra.FormatPromExposition(samples, time.Now().UnixMilli()))
|
||||
// nats_msgs_in_total{instance="magnus",node="magnus"} 17 ...
|
||||
// kv_bucket_msgs{bucket="UNIBUS_users",instance="magnus",node="magnus"} 2 ...
|
||||
// nats_jetstream_raft_leader{instance="magnus",node="magnus",stream="KV_UNIBUS_users"} 1 ...
|
||||
}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Úsala dentro de un exporter que monitoriza un nats-server con el monitoring HTTP
|
||||
embebido activado (`http: 127.0.0.1:8222` en la config de NATS): tras hacer
|
||||
`GET /varz`, `GET /connz` y `GET /jsz?streams=1` contra loopback, pasa los tres cuerpos
|
||||
crudos a esta función para obtener todas las series server-level del nodo. Llámala como
|
||||
scraper local por nodo (cada nodo expone su 8222 solo en loopback), no centralizado.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura por contrato**: solo devuelve `error` si `varz` no es JSON válido (es el core).
|
||||
`connz` y `jsz` son **best-effort**: si vienen vacíos o no parsean, sus series se omiten
|
||||
sin abortar. Esto hace al scraper resistente a que un endpoint falle de forma puntual.
|
||||
- **Monitoring loopback-only sin auth**: el puerto 8222 de NATS no tiene autenticación; por
|
||||
eso debe bindearse a `127.0.0.1` y scrapearse localmente en cada nodo, nunca exponerse a
|
||||
la red. El push agregado a VictoriaMetrics lo hace el exporter, no esta función.
|
||||
- **`/jsz` necesita `?streams=1`** para traer `account_details[].stream_detail[]`. Sin ese
|
||||
parámetro el cuerpo trae los totales pero no el detalle por stream, y entonces no salen
|
||||
`nats_stream_*`, `nats_jetstream_raft_leader` ni `kv_bucket_msgs`.
|
||||
- **`nats_connections`**: prefiere `connz.num_connections`; si `connz` no parsea, cae a
|
||||
`varz.connections` para no perder la serie.
|
||||
- **RAFT leader en standalone**: en un nats-server sin clúster, el objeto `cluster` puede
|
||||
faltar o `leader` venir vacío; en ese caso `nats_jetstream_raft_leader` sale 0 salvo que
|
||||
`cluster.leader == node`. Es esperado: en standalone no hay quorum RAFT real.
|
||||
- **`kv_bucket_msgs`** solo se emite para streams cuyo nombre empieza por `KV_`, recortando
|
||||
el prefijo (stream `KV_UNIBUS_users` → bucket `UNIBUS_users`).
|
||||
- **`nats_server_start_seconds`** es el epoch Unix del campo `start` (RFC3339): sirve como
|
||||
proxy de reinicios (un cambio de valor = el server reinició). Si el campo no parsea como
|
||||
fecha válida, la serie se omite en lugar de abortar.
|
||||
@@ -0,0 +1,160 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// findNatsSample devuelve el primer PromSample cuyo Name coincide y cuyos labels
|
||||
// extra (clave/valor alternados) están todos presentes con el valor esperado.
|
||||
// El segundo retorno indica si se encontró.
|
||||
func findNatsSample(samples []PromSample, name string, labels ...string) (PromSample, bool) {
|
||||
for _, s := range samples {
|
||||
if s.Name != name {
|
||||
continue
|
||||
}
|
||||
match := true
|
||||
for i := 0; i+1 < len(labels); i += 2 {
|
||||
if s.Labels[labels[i]] != labels[i+1] {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if match {
|
||||
return s, true
|
||||
}
|
||||
}
|
||||
return PromSample{}, false
|
||||
}
|
||||
|
||||
func mustRead(t *testing.T, path string) []byte {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read fixture %s: %v", path, err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// golden: fixtures reales de un nats-server 2.11.15, node="probe" (== el leader
|
||||
// de los streams), valores concretos verificados a mano.
|
||||
func TestParseNatsMonitorGolden(t *testing.T) {
|
||||
varz := mustRead(t, "testdata/nats_varz.json")
|
||||
connz := mustRead(t, "testdata/nats_connz.json")
|
||||
jsz := mustRead(t, "testdata/nats_jsz.json")
|
||||
|
||||
got, err := ParseNatsMonitor("probe", varz, connz, jsz)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
want := map[string]float64{
|
||||
"nats_msgs_in_total": 17,
|
||||
"nats_msgs_out_total": 17,
|
||||
"nats_mem_bytes": 18288640,
|
||||
"nats_jetstream_streams": 3,
|
||||
"nats_connections": 1,
|
||||
"nats_jetstream_messages": 6,
|
||||
}
|
||||
for name, w := range want {
|
||||
s, ok := findNatsSample(got, name)
|
||||
if !ok {
|
||||
t.Errorf("missing sample %q", name)
|
||||
continue
|
||||
}
|
||||
if s.Value != w {
|
||||
t.Errorf("%s = %v, want %v", name, s.Value, w)
|
||||
}
|
||||
if s.Labels["node"] != "probe" || s.Labels["instance"] != "probe" {
|
||||
t.Errorf("%s labels = %v, want node=instance=probe", name, s.Labels)
|
||||
}
|
||||
}
|
||||
|
||||
// kv_bucket_msgs por cada KV bucket (prefijo KV_ recortado).
|
||||
for bucket, w := range map[string]float64{
|
||||
"UNIBUS_users": 2,
|
||||
"UNIBUS_rooms": 2,
|
||||
"UNIBUS_members": 2,
|
||||
} {
|
||||
s, ok := findNatsSample(got, "kv_bucket_msgs", "bucket", bucket)
|
||||
if !ok {
|
||||
t.Errorf("missing kv_bucket_msgs{bucket=%q}", bucket)
|
||||
continue
|
||||
}
|
||||
if s.Value != w {
|
||||
t.Errorf("kv_bucket_msgs{bucket=%q} = %v, want %v", bucket, s.Value, w)
|
||||
}
|
||||
}
|
||||
|
||||
// raft leader: probe == node, así que el stream KV_UNIBUS_users tiene leader=1.
|
||||
s, ok := findNatsSample(got, "nats_jetstream_raft_leader", "stream", "KV_UNIBUS_users")
|
||||
if !ok {
|
||||
t.Fatal("missing nats_jetstream_raft_leader{stream=KV_UNIBUS_users}")
|
||||
}
|
||||
if s.Value != 1 {
|
||||
t.Errorf("nats_jetstream_raft_leader{stream=KV_UNIBUS_users} = %v, want 1", s.Value)
|
||||
}
|
||||
|
||||
// stream_detail también emite nats_stream_messages con label stream completo.
|
||||
if s, ok := findNatsSample(got, "nats_stream_messages", "stream", "KV_UNIBUS_users"); !ok || s.Value != 2 {
|
||||
t.Errorf("nats_stream_messages{stream=KV_UNIBUS_users} = %v ok=%v, want 2", s.Value, ok)
|
||||
}
|
||||
|
||||
// nats_server_start_seconds presente (start es RFC3339 válido).
|
||||
if _, ok := findNatsSample(got, "nats_server_start_seconds"); !ok {
|
||||
t.Error("missing nats_server_start_seconds (start is a valid RFC3339)")
|
||||
}
|
||||
}
|
||||
|
||||
// edge: jsz sin streams ni account_details. No produce series kv_bucket_msgs ni
|
||||
// nats_stream_*, pero sí las de varz/connz y las jetstream top-level (en 0).
|
||||
func TestParseNatsMonitorEmptyJsz(t *testing.T) {
|
||||
varz := mustRead(t, "testdata/nats_varz.json")
|
||||
connz := mustRead(t, "testdata/nats_connz.json")
|
||||
jsz := []byte(`{"streams":0,"account_details":[]}`)
|
||||
|
||||
got, err := ParseNatsMonitor("probe", varz, connz, jsz)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if _, ok := findNatsSample(got, "kv_bucket_msgs", "bucket", "UNIBUS_users"); ok {
|
||||
t.Error("did not expect kv_bucket_msgs with empty account_details")
|
||||
}
|
||||
if _, ok := findNatsSample(got, "nats_stream_messages"); ok {
|
||||
t.Error("did not expect nats_stream_messages with empty account_details")
|
||||
}
|
||||
// varz/connz siguen presentes.
|
||||
if s, ok := findNatsSample(got, "nats_msgs_in_total"); !ok || s.Value != 17 {
|
||||
t.Errorf("nats_msgs_in_total = %v ok=%v, want 17", s.Value, ok)
|
||||
}
|
||||
if s, ok := findNatsSample(got, "nats_connections"); !ok || s.Value != 1 {
|
||||
t.Errorf("nats_connections = %v ok=%v, want 1", s.Value, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// edge: connz inválido. No es error; nats_connections cae a varz.connections (1).
|
||||
// varz/jsz siguen produciendo sus series.
|
||||
func TestParseNatsMonitorInvalidConnz(t *testing.T) {
|
||||
varz := mustRead(t, "testdata/nats_varz.json")
|
||||
jsz := mustRead(t, "testdata/nats_jsz.json")
|
||||
|
||||
got, err := ParseNatsMonitor("probe", varz, []byte("not json"), jsz)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// fallback a varz.connections (= 1).
|
||||
if s, ok := findNatsSample(got, "nats_connections"); !ok || s.Value != 1 {
|
||||
t.Errorf("nats_connections = %v ok=%v, want 1 (fallback varz.connections)", s.Value, ok)
|
||||
}
|
||||
// jsz sigue vivo.
|
||||
if s, ok := findNatsSample(got, "nats_jetstream_streams"); !ok || s.Value != 3 {
|
||||
t.Errorf("nats_jetstream_streams = %v ok=%v, want 3", s.Value, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// error path: varz inválido devuelve error no-nil (es el core, sin él no hay nada).
|
||||
func TestParseNatsMonitorInvalidVarz(t *testing.T) {
|
||||
if _, err := ParseNatsMonitor("probe", []byte("{{{"), nil, nil); err == nil {
|
||||
t.Fatal("expected error for invalid varz, got nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// unibusHealth refleja la respuesta JSON del endpoint /healthz de un nodo del
|
||||
// cluster de mensajería unibus (membershipd). Forma verificada en producción:
|
||||
//
|
||||
// {"posture":{"enforce":true,"acl":true,"tls":true,"cluster":true,"store":"kv"},"status":"ok"}
|
||||
type unibusHealth struct {
|
||||
Status string `json:"status"`
|
||||
Posture struct {
|
||||
Enforce bool `json:"enforce"`
|
||||
ACL bool `json:"acl"`
|
||||
TLS bool `json:"tls"`
|
||||
Cluster bool `json:"cluster"`
|
||||
Store string `json:"store"`
|
||||
} `json:"posture"`
|
||||
}
|
||||
|
||||
// ParseUnibusHealth convierte la respuesta JSON del endpoint /healthz de un nodo
|
||||
// del cluster de mensajería unibus en una serie de PromSample lista para empujar
|
||||
// a VictoriaMetrics, sin instrumentar el bus (solo lee su endpoint de salud).
|
||||
//
|
||||
// node es el nombre lógico del nodo (p.ej. "magnus"); se adjunta a cada serie
|
||||
// como las labels "node" e "instance" para distinguir los nodos cuando un único
|
||||
// exporter scrapea varios. La función SOLO debe llamarse cuando el nodo
|
||||
// respondió: el caso "no responde" (unibus_up=0) lo emite el llamador, no esta
|
||||
// función, porque sin cuerpo no hay nada que parsear.
|
||||
//
|
||||
// Devuelve siete series por nodo:
|
||||
// - unibus_up = 1 (si el body parseó, el nodo respondió)
|
||||
// - unibus_status_ok = 1 si status=="ok", si no 0
|
||||
// - unibus_posture_enforce / _acl / _tls / _cluster = 1/0 según el booleano
|
||||
// - unibus_store_kv = 1 si posture.store=="kv", si no 0
|
||||
//
|
||||
// Si el body no es JSON válido con la forma esperada, devuelve (nil, error).
|
||||
func ParseUnibusHealth(node string, body []byte) ([]PromSample, error) {
|
||||
var h unibusHealth
|
||||
if err := json.Unmarshal(body, &h); err != nil {
|
||||
return nil, fmt.Errorf("parse unibus healthz for node %q: %w", node, err)
|
||||
}
|
||||
b2f := func(b bool) float64 {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
mk := func(name string, v float64) PromSample {
|
||||
return PromSample{
|
||||
Name: name,
|
||||
Labels: map[string]string{"node": node, "instance": node},
|
||||
Value: v,
|
||||
}
|
||||
}
|
||||
return []PromSample{
|
||||
mk("unibus_up", 1),
|
||||
mk("unibus_status_ok", b2f(h.Status == "ok")),
|
||||
mk("unibus_posture_enforce", b2f(h.Posture.Enforce)),
|
||||
mk("unibus_posture_acl", b2f(h.Posture.ACL)),
|
||||
mk("unibus_posture_tls", b2f(h.Posture.TLS)),
|
||||
mk("unibus_posture_cluster", b2f(h.Posture.Cluster)),
|
||||
mk("unibus_store_kv", b2f(h.Posture.Store == "kv")),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
name: parse_unibus_health
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func ParseUnibusHealth(node string, body []byte) ([]PromSample, error)"
|
||||
description: "Convierte la respuesta JSON del endpoint /healthz de un nodo del cluster de mensajería unibus (membershipd) en una serie de PromSample lista para empujar a VictoriaMetrics, sin instrumentar el bus: solo lee su endpoint de salud. Adjunta a cada serie las labels node e instance (= nombre lógico del nodo) para distinguir los nodos cuando un único exporter scrapea varios. Emite siete series por nodo: unibus_up, unibus_status_ok, unibus_posture_enforce/acl/tls/cluster y unibus_store_kv. Devuelve error si el body no es JSON válido con la forma esperada."
|
||||
tags: [prometheus, metrics, unibus, nats, healthz, posture, fleet-metrics, infra, monitoring]
|
||||
uses_functions: []
|
||||
uses_types: ["PromSample_go_infra"]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["encoding/json", "fmt"]
|
||||
params:
|
||||
- name: node
|
||||
desc: "nombre lógico del nodo (p.ej. \"magnus\"); se adjunta como labels node e instance a cada serie"
|
||||
- name: body
|
||||
desc: "cuerpo JSON crudo devuelto por GET https://<nodo>:8470/healthz, forma {\"posture\":{enforce,acl,tls,cluster bool; store string},\"status\":string}"
|
||||
output: "slice de 7 PromSample con labels {node,instance}: unibus_up=1, unibus_status_ok (1 si status==ok), unibus_posture_enforce/acl/tls/cluster (1/0), unibus_store_kv (1 si posture.store==kv). Error si el body no es JSON válido."
|
||||
tested: true
|
||||
test_file_path: "functions/infra/parse_unibus_health_test.go"
|
||||
tests:
|
||||
- "TestParseUnibusHealthGolden"
|
||||
- "TestParseUnibusHealthDegraded"
|
||||
- "TestParseUnibusHealthInvalid"
|
||||
---
|
||||
|
||||
# parse_unibus_health
|
||||
|
||||
Función pura de transformación (clasificada `impure` solo porque devuelve `error` al
|
||||
fallar el unmarshal; no hace I/O ni red) que traduce la salud de un nodo del bus de
|
||||
mensajería **unibus** a métricas Prometheus. Pertenece al grupo de capacidad
|
||||
`fleet-metrics`: se compone con `format_prom_exposition_go_infra` (serializar) y
|
||||
`push_prom_remote_go_infra` (empujar a VictoriaMetrics).
|
||||
|
||||
El endpoint `/healthz` de cada nodo (`membershipd`) responde, verificado en producción:
|
||||
|
||||
```json
|
||||
{"posture":{"enforce":true,"acl":true,"tls":true,"cluster":true,"store":"kv"},"status":"ok"}
|
||||
```
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"fn-registry/functions/infra"
|
||||
)
|
||||
|
||||
func main() {
|
||||
body := []byte(`{"posture":{"enforce":true,"acl":true,"tls":true,"cluster":true,"store":"kv"},"status":"ok"}`)
|
||||
samples, err := infra.ParseUnibusHealth("magnus", body)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
// Serializa y (en un exporter real) empuja a VictoriaMetrics.
|
||||
fmt.Print(infra.FormatPromExposition(samples, time.Now().UnixMilli()))
|
||||
// unibus_up{instance="magnus",node="magnus"} 1 ...
|
||||
// unibus_posture_enforce{instance="magnus",node="magnus"} 1 ...
|
||||
}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Úsala dentro de un exporter que monitoriza el cluster unibus: tras hacer
|
||||
`GET https://<nodo>:8470/healthz` con la CA del cluster, pasa el cuerpo a esta función
|
||||
para obtener las series del nodo. Llámala **solo cuando el nodo respondió**; si el GET
|
||||
falla (timeout, TLS, no-2xx), emite tú `unibus_up=0` para ese nodo, porque sin cuerpo
|
||||
no hay nada que parsear.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- No emite `unibus_up=0`: ese caso (nodo caído) es responsabilidad del llamador, que sabe
|
||||
si el GET falló. Esta función siempre emite `unibus_up=1` porque solo se la llama con un
|
||||
cuerpo recibido.
|
||||
- Las labels `node` e `instance` toman el mismo valor (el nombre lógico del nodo). El
|
||||
`push_prom_remote_go_infra` añadiría `instance` vía `extra_label` por igual a todas las
|
||||
series del body; por eso aquí ya se fija `instance` por-serie, para que cada nodo unibus
|
||||
conserve su identidad cuando un solo exporter empuja los de varios nodos en un único POST.
|
||||
- Solo lee la posture y el status que hoy expone `/healthz`. Métricas profundas de
|
||||
NATS/JetStream (msgs/s, conexiones, RAFT leader por stream) NO salen de aquí: requieren
|
||||
el monitoring embebido de NATS (puerto 8222), que en producción está cerrado.
|
||||
@@ -0,0 +1,67 @@
|
||||
package infra
|
||||
|
||||
import "testing"
|
||||
|
||||
// golden: nodo seguro con la posture homogénea esperada en producción.
|
||||
func TestParseUnibusHealthGolden(t *testing.T) {
|
||||
body := []byte(`{"posture":{"enforce":true,"acl":true,"tls":true,"cluster":true,"store":"kv"},"status":"ok"}`)
|
||||
got, err := ParseUnibusHealth("magnus", body)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := map[string]float64{
|
||||
"unibus_up": 1,
|
||||
"unibus_status_ok": 1,
|
||||
"unibus_posture_enforce": 1,
|
||||
"unibus_posture_acl": 1,
|
||||
"unibus_posture_tls": 1,
|
||||
"unibus_posture_cluster": 1,
|
||||
"unibus_store_kv": 1,
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %d samples, want %d", len(got), len(want))
|
||||
}
|
||||
for _, s := range got {
|
||||
w, ok := want[s.Name]
|
||||
if !ok {
|
||||
t.Errorf("unexpected sample %q", s.Name)
|
||||
continue
|
||||
}
|
||||
if s.Value != w {
|
||||
t.Errorf("%s = %v, want %v", s.Name, s.Value, w)
|
||||
}
|
||||
if s.Labels["node"] != "magnus" || s.Labels["instance"] != "magnus" {
|
||||
t.Errorf("%s labels = %v, want node=instance=magnus", s.Name, s.Labels)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// edge: nodo degradado (posture todo false, store distinto de kv, status != ok).
|
||||
func TestParseUnibusHealthDegraded(t *testing.T) {
|
||||
body := []byte(`{"posture":{"enforce":false,"acl":false,"tls":false,"cluster":false,"store":"sqlite"},"status":"degraded"}`)
|
||||
got, err := ParseUnibusHealth("homer", body)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := map[string]float64{
|
||||
"unibus_up": 1,
|
||||
"unibus_status_ok": 0,
|
||||
"unibus_posture_enforce": 0,
|
||||
"unibus_posture_acl": 0,
|
||||
"unibus_posture_tls": 0,
|
||||
"unibus_posture_cluster": 0,
|
||||
"unibus_store_kv": 0,
|
||||
}
|
||||
for _, s := range got {
|
||||
if s.Value != want[s.Name] {
|
||||
t.Errorf("%s = %v, want %v", s.Name, s.Value, want[s.Name])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// error path: body que no es JSON válido devuelve error, no panic.
|
||||
func TestParseUnibusHealthInvalid(t *testing.T) {
|
||||
if _, err := ParseUnibusHealth("datardos", []byte("not json at all")); err == nil {
|
||||
t.Fatal("expected error for invalid body, got nil")
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"server_id": "NC23B47RQSJYPX5AIUC5CA3ND5RLCYREKSAFLM65MLBY5PBRIXPAFL7O",
|
||||
"now": "2026-06-07T19:02:25.326833943Z",
|
||||
"num_connections": 1,
|
||||
"total": 1,
|
||||
"offset": 0,
|
||||
"limit": 1024,
|
||||
"connections": [
|
||||
{
|
||||
"cid": 5,
|
||||
"kind": "Client",
|
||||
"type": "nats",
|
||||
"ip": "127.0.0.1",
|
||||
"port": 52734,
|
||||
"start": "2026-06-07T21:02:24.812382826+02:00",
|
||||
"last_activity": "2026-06-07T21:02:24.821005187+02:00",
|
||||
"rtt": "623µs",
|
||||
"uptime": "0s",
|
||||
"idle": "0s",
|
||||
"pending_bytes": 0,
|
||||
"in_msgs": 17,
|
||||
"out_msgs": 17,
|
||||
"in_bytes": 1304,
|
||||
"out_bytes": 3905,
|
||||
"subscriptions": 2,
|
||||
"lang": "go",
|
||||
"version": "1.49.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"memory": 0,
|
||||
"storage": 310,
|
||||
"reserved_memory": 0,
|
||||
"reserved_storage": 0,
|
||||
"accounts": 1,
|
||||
"ha_assets": 0,
|
||||
"api": {
|
||||
"level": 1,
|
||||
"total": 6,
|
||||
"errors": 0
|
||||
},
|
||||
"server_id": "NC23B47RQSJYPX5AIUC5CA3ND5RLCYREKSAFLM65MLBY5PBRIXPAFL7O",
|
||||
"now": "2026-06-07T19:02:25.327216549Z",
|
||||
"config": {
|
||||
"max_memory": 3221225472,
|
||||
"max_storage": 546399169536,
|
||||
"store_dir": "/tmp/natsprobe4019469486/jetstream",
|
||||
"sync_interval": 120000000000
|
||||
},
|
||||
"limits": {},
|
||||
"streams": 3,
|
||||
"consumers": 0,
|
||||
"messages": 6,
|
||||
"bytes": 310,
|
||||
"account_details": [
|
||||
{
|
||||
"name": "$G",
|
||||
"id": "$G",
|
||||
"memory": 0,
|
||||
"storage": 310,
|
||||
"reserved_memory": 18446744073709551615,
|
||||
"reserved_storage": 18446744073709551615,
|
||||
"accounts": 0,
|
||||
"ha_assets": 0,
|
||||
"api": {
|
||||
"level": 0,
|
||||
"total": 6,
|
||||
"errors": 0
|
||||
},
|
||||
"stream_detail": [
|
||||
{
|
||||
"name": "KV_UNIBUS_rooms",
|
||||
"created": "2026-06-07T19:02:24.8170934Z",
|
||||
"cluster": {
|
||||
"leader": "probe"
|
||||
},
|
||||
"state": {
|
||||
"messages": 2,
|
||||
"bytes": 102,
|
||||
"first_seq": 1,
|
||||
"first_ts": "2026-06-07T19:02:24.817910599Z",
|
||||
"last_seq": 2,
|
||||
"last_ts": "2026-06-07T19:02:24.818011867Z",
|
||||
"num_subjects": 2,
|
||||
"consumer_count": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "KV_UNIBUS_members",
|
||||
"created": "2026-06-07T19:02:24.818494147Z",
|
||||
"cluster": {
|
||||
"leader": "probe"
|
||||
},
|
||||
"state": {
|
||||
"messages": 2,
|
||||
"bytes": 106,
|
||||
"first_seq": 1,
|
||||
"first_ts": "2026-06-07T19:02:24.81917932Z",
|
||||
"last_seq": 2,
|
||||
"last_ts": "2026-06-07T19:02:24.819283444Z",
|
||||
"num_subjects": 2,
|
||||
"consumer_count": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "KV_UNIBUS_users",
|
||||
"created": "2026-06-07T19:02:24.814500069Z",
|
||||
"cluster": {
|
||||
"leader": "probe"
|
||||
},
|
||||
"state": {
|
||||
"messages": 2,
|
||||
"bytes": 102,
|
||||
"first_seq": 1,
|
||||
"first_ts": "2026-06-07T19:02:24.81638123Z",
|
||||
"last_seq": 2,
|
||||
"last_ts": "2026-06-07T19:02:24.816570377Z",
|
||||
"num_subjects": 2,
|
||||
"consumer_count": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"server_id": "NC23B47RQSJYPX5AIUC5CA3ND5RLCYREKSAFLM65MLBY5PBRIXPAFL7O",
|
||||
"server_name": "probe",
|
||||
"version": "2.11.15",
|
||||
"proto": 1,
|
||||
"go": "go1.26.4",
|
||||
"host": "127.0.0.1",
|
||||
"port": 14260,
|
||||
"max_connections": 65536,
|
||||
"ping_interval": 120000000000,
|
||||
"ping_max": 2,
|
||||
"http_host": "127.0.0.1",
|
||||
"http_port": 8222,
|
||||
"http_base_path": "",
|
||||
"https_port": 0,
|
||||
"auth_timeout": 2,
|
||||
"max_control_line": 4096,
|
||||
"max_payload": 1048576,
|
||||
"max_pending": 67108864,
|
||||
"cluster": {},
|
||||
"gateway": {},
|
||||
"leaf": {},
|
||||
"mqtt": {},
|
||||
"websocket": {},
|
||||
"jetstream": {
|
||||
"config": {
|
||||
"max_memory": 3221225472,
|
||||
"max_storage": 546399169536,
|
||||
"store_dir": "/tmp/natsprobe4019469486/jetstream",
|
||||
"sync_interval": 120000000000
|
||||
},
|
||||
"stats": {
|
||||
"memory": 0,
|
||||
"storage": 310,
|
||||
"reserved_memory": 0,
|
||||
"reserved_storage": 0,
|
||||
"accounts": 1,
|
||||
"ha_assets": 0,
|
||||
"api": {
|
||||
"level": 1,
|
||||
"total": 6,
|
||||
"errors": 0
|
||||
}
|
||||
},
|
||||
"limits": {}
|
||||
},
|
||||
"tls_timeout": 2,
|
||||
"write_deadline": 10000000000,
|
||||
"start": "2026-06-07T19:02:24.785745698Z",
|
||||
"now": "2026-06-07T19:02:25.325501038Z",
|
||||
"uptime": "0s",
|
||||
"mem": 18288640,
|
||||
"cores": 24,
|
||||
"gomaxprocs": 24,
|
||||
"gomemlimit": 4294967296,
|
||||
"cpu": 0,
|
||||
"connections": 1,
|
||||
"total_connections": 1,
|
||||
"routes": 0,
|
||||
"remotes": 0,
|
||||
"leafnodes": 0,
|
||||
"in_msgs": 17,
|
||||
"out_msgs": 17,
|
||||
"in_bytes": 1304,
|
||||
"out_bytes": 3905,
|
||||
"slow_consumers": 0,
|
||||
"subscriptions": 75,
|
||||
"http_req_stats": {
|
||||
"/varz": 1
|
||||
},
|
||||
"config_load_time": "2026-06-07T19:02:24.785745698Z",
|
||||
"config_digest": "",
|
||||
"system_account": "$SYS",
|
||||
"slow_consumer_stats": {
|
||||
"clients": 0,
|
||||
"routes": 0,
|
||||
"gateways": 0,
|
||||
"leafs": 0
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user