Skip to content

Commit cd4619c

Browse files
committed
merge: dev2 → master — gerarchia Capitano↔Sentinella ribaltata, capitano-bridge heartbeat orario, analista 1-pos/turno
2 parents ebc143f + c1c5fab commit cd4619c

8 files changed

Lines changed: 517 additions & 15 deletions

File tree

.launcher/capitano-bridge.py

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
#!/usr/bin/env python3
2+
"""
3+
capitano-bridge.py — heartbeat ORARIO al Capitano (2026-06-26).
4+
5+
Perché esiste: col push→pull (bridge→Sentinella) il Capitano non riceve più il
6+
[BRIDGE PACING] ogni 15 min, e si è osservato che resta INCAGLIATO quando la
7+
Sentinella tace (steady/calm) — defer-a-un-tick-che-non-viene. Questo bridge è il
8+
suo BATTITO: lo risveglia 1×/ora (allo scoccare dell'ora) con un nudge che lo fa
9+
RAGIONARE — non è la Sentinella (quella analizza il budget), è uno strumento
10+
DETERMINISTICO (no LLM) al servizio del Capitano per non lasciarlo passivo.
11+
12+
Intelligente, NON ripetitivo: legge i dati (code DB, top-consumer, budget) e
13+
sceglie il nudge PIÙ RILEVANTE adesso, variando il tema. A volte tace (se tutto
14+
è palesemente in regola e ha già nudgeato di recente) — un battito non è un
15+
obbligo a scrivere.
16+
17+
NON decide al posto del Capitano: pone una domanda / segnala una condizione e
18+
lascia che sia LUI a verificare (con le sue skill) e decidere.
19+
20+
Output:
21+
- stdout (→ /tmp/capitano-bridge.log)
22+
- tmux send al CAPITANO via jht-tmux-send (single-line)
23+
24+
Modi:
25+
python3 capitano-bridge.py # loop orario allineato a :00
26+
python3 capitano-bridge.py --once # un colpo, stampa, niente send
27+
python3 capitano-bridge.py --once --send
28+
"""
29+
import json
30+
import os
31+
import subprocess
32+
import sys
33+
import time
34+
from datetime import datetime, timedelta, timezone
35+
from pathlib import Path
36+
37+
JHT_HOME = Path(os.environ.get("JHT_HOME", "/jht_home"))
38+
LOGS_DIR = JHT_HOME / "logs"
39+
SENTINEL_JSONL = LOGS_DIR / "sentinel-data.jsonl"
40+
AGENT_TABLE_FILE = LOGS_DIR / "agent-usage-table.json"
41+
DB_PATH = JHT_HOME / "jobs.db"
42+
STATE_FILE = LOGS_DIR / "capitano-bridge-state.json"
43+
TARGET = os.environ.get("JHT_CAPITANO_HEARTBEAT_SESSION", "CAPITANO")
44+
DB_QUERY = "/app/shared/skills/db_query.py"
45+
46+
47+
def _log(msg):
48+
print(f"[capitano-bridge] {msg}", file=sys.stdout, flush=True)
49+
50+
51+
def _db_count(cmd):
52+
"""Conta le righe-posizione di un db_query (next-for-*). Ritorna int o None.
53+
Robusto: "nessuna"/"non" → 0; errori → None (campo omesso, non inventato)."""
54+
try:
55+
out = subprocess.run(
56+
["python3", DB_QUERY, cmd], capture_output=True, text=True, timeout=30
57+
).stdout
58+
except Exception:
59+
return None
60+
low = out.lower()
61+
if "nessun" in low or "no positions" in low or "vuota" in low or "empty" in low:
62+
return 0
63+
# conta righe che sembrano una posizione (iniziano con # o con un id numerico)
64+
n = 0
65+
for line in out.splitlines():
66+
s = line.strip()
67+
if s.startswith("#") or (s[:1].isdigit() and "|" in s):
68+
n += 1
69+
return n
70+
71+
72+
def _last_sentinel():
73+
try:
74+
last = None
75+
with open(SENTINEL_JSONL, encoding="utf-8") as f:
76+
for line in f:
77+
line = line.strip()
78+
if not line:
79+
continue
80+
try:
81+
last = json.loads(line)
82+
except Exception:
83+
continue
84+
return last or {}
85+
except Exception:
86+
return {}
87+
88+
89+
def _top_consumer():
90+
"""(name, pct_share) del worker che brucia di più nell'ultima finestra, o None."""
91+
try:
92+
t = json.loads(AGENT_TABLE_FILE.read_text(encoding="utf-8"))
93+
agents = t.get("agents") or []
94+
best = None
95+
for a in agents:
96+
share = a.get("share")
97+
name = a.get("name")
98+
cad = a.get("cadence_per_min")
99+
if not isinstance(share, (int, float)) or not name:
100+
continue
101+
if best is None or share > best[1]:
102+
best = (name, share, cad)
103+
return best
104+
except Exception:
105+
return None
106+
107+
108+
def gather_state():
109+
s = _last_sentinel()
110+
return {
111+
"weekly": s.get("weekly_usage"),
112+
"usage5h": s.get("usage"),
113+
"status": s.get("status"),
114+
"work_phase": s.get("work_phase"),
115+
"q_analista": _db_count("next-for-analista"),
116+
"q_scorer": _db_count("next-for-scorer"),
117+
"top": _top_consumer(),
118+
}
119+
120+
121+
def _live_sessions():
122+
try:
123+
out = subprocess.run(
124+
["tmux", "ls"], capture_output=True, text=True, timeout=10
125+
).stdout
126+
return [l.split(":", 1)[0] for l in out.splitlines() if ":" in l]
127+
except Exception:
128+
return []
129+
130+
131+
def choose_nudge(state, hour, last_theme):
132+
"""Sceglie il nudge PIÙ RILEVANTE adesso (deterministico, vario). Ritorna
133+
(theme, message) o (None, None) per tacere. Priorità: condizioni anomale
134+
prima, poi rotazione a tema per non ripetersi."""
135+
sessions = [s.upper() for s in _live_sessions()]
136+
scouts_live = [s for s in sessions if s.startswith("SCOUT")]
137+
qa, qs = state.get("q_analista"), state.get("q_scorer")
138+
top = state.get("top")
139+
wk = state.get("weekly")
140+
141+
# 1) PIPELINE FERMA: code vuote + nessuno Scout → sourcing fermo (lo scenario
142+
# osservato: Capitano incagliato a pipeline vuota). Priorità massima.
143+
if qa == 0 and qs == 0 and not scouts_live:
144+
return ("pipeline-ferma",
145+
f"[HEARTBEAT] Code VUOTE (analista={qa}, scorer={qs}) e NESSUNO Scout "
146+
f"attivo → il sourcing è fermo. weekly={wk}%. Se hai margine di budget, "
147+
f"perché non stai sorgendo? Verifica (pipeline-triage / rate-budget) e "
148+
f"decidi se spawnare uno Scout. (nudge orario, decidi tu)")
149+
150+
# 2) WORKER CALDO: top-consumer con share alto e cadenza ~0 = sospetto
151+
# rabbit-hole/stuck → fai verificare al Capitano (non killare tu).
152+
if top and top[1] >= 50 and (top[2] is None or top[2] < 0.05):
153+
return ("worker-caldo",
154+
f"[HEARTBEAT] {top[0]} brucia ~{top[1]:.0f}% del team con cadenza ~0 "
155+
f"nell'ultima finestra → potrebbe essere un task lungo o un rabbit-hole. "
156+
f"Dagli un'occhiata (capture-pane / agent-speed-table): se non produce, "
157+
f"valuta Continua o KILL. (nudge orario, decidi tu)")
158+
159+
# 3) BACKLOG: code profonde → forse servono più worker.
160+
if (qa or 0) >= 15 or (qs or 0) >= 15:
161+
return ("backlog",
162+
f"[HEARTBEAT] Coda profonda (analista={qa}, scorer={qs}). Se il budget "
163+
f"regge, valuta se scalare i worker sul collo di bottiglia. (decidi tu)")
164+
165+
# 4) Rotazione leggera per non ripetere lo stesso tema; a volte SILENZIO.
166+
rota = [
167+
("pacing-check",
168+
f"[HEARTBEAT] Stato: weekly={wk}% status={state.get('status')}. Sei nella "
169+
f"banda di pace giusta? Se in dubbio, tira rate-budget e ricalibra. (decidi tu)"),
170+
("code-check",
171+
f"[HEARTBEAT] Code: analista={qa}, scorer={qs}. Pipeline sana? Un giro di "
172+
f"pipeline-triage se qualcosa non ti torna. (decidi tu)"),
173+
(None, None), # un'ora su tre: silenzio
174+
]
175+
theme, msg = rota[hour % len(rota)]
176+
if theme is not None and theme == last_theme:
177+
return (None, None) # non ripetere lo stesso tema due ore di fila
178+
return (theme, msg)
179+
180+
181+
def _send(msg):
182+
for cand in ("jht-tmux-send",
183+
"/app/agents/_skills/tmux-send/jht-tmux-send"):
184+
try:
185+
r = subprocess.run([cand, TARGET, msg], capture_output=True,
186+
text=True, timeout=90)
187+
if r.returncode == 0:
188+
return True
189+
except FileNotFoundError:
190+
continue
191+
except Exception:
192+
return False
193+
return False
194+
195+
196+
def _read_state():
197+
try:
198+
return json.loads(STATE_FILE.read_text(encoding="utf-8"))
199+
except Exception:
200+
return {}
201+
202+
203+
def _write_state(d):
204+
try:
205+
STATE_FILE.write_text(json.dumps(d), encoding="utf-8")
206+
except Exception:
207+
pass
208+
209+
210+
def tick(now, send):
211+
st = gather_state()
212+
persisted = _read_state()
213+
theme, msg = choose_nudge(st, now.hour, persisted.get("last_theme"))
214+
if not msg:
215+
_log(f"{now:%H:%M} silent (theme={theme}) state={st}")
216+
return
217+
_log(f"{now:%H:%M} nudge[{theme}]: {msg}")
218+
if send:
219+
ok = _send(msg)
220+
_log(f"send → {'ok' if ok else 'FAIL'}")
221+
if ok:
222+
_write_state({"last_theme": theme, "last_ts": now.isoformat()})
223+
224+
225+
def main():
226+
once = "--once" in sys.argv
227+
send = ("--send" in sys.argv) or not once
228+
if once:
229+
tick(datetime.now(timezone.utc), send)
230+
return
231+
_log(f"up — heartbeat orario al {TARGET}, jht_home={JHT_HOME}")
232+
while True:
233+
now = datetime.now(timezone.utc)
234+
nxt = (now + timedelta(hours=1)).replace(minute=0, second=0, microsecond=0)
235+
time.sleep(max(1, (nxt - now).total_seconds()))
236+
try:
237+
tick(datetime.now(timezone.utc), send=True)
238+
except Exception as e:
239+
_log(f"tick error: {e}")
240+
241+
242+
if __name__ == "__main__":
243+
main()

.launcher/start-agent.sh

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -149,12 +149,14 @@ if [ "$ROLE" = "bridge" ]; then
149149
" >/dev/null 2>&1 < /dev/null &
150150
echo "✓ sentinel-bridge partito (target=${JHT_TARGET_SESSION:-CAPITANO}, log /tmp/sentinel-bridge.log)"
151151

152-
# Pacing bridge — tick orario al CAPITANO sul ritmo del team. Stesso
152+
# Pacing bridge — tick alla SENTINELLA (analista del pacing) sul ritmo del
153+
# team (2026-06-25 push→pull: NON più al Capitano, vedi bridge-to-sentinella
154+
# doc). Stesso
153155
# pattern del sentinel-bridge: setsid + singleton tramite kill via
154156
# /proc/*/cmdline + log su /tmp. Indipendente dal sentinel-bridge:
155157
# legge sentinel-data.jsonl (scritto dal sentinel-bridge) + token logs
156158
# locali, calcola Δusage / vel_team / vel_target / %/h per agente, e
157-
# manda un [BRIDGE PACING] al Capitano allineato a :00,:15,:30,:45 UTC.
159+
# manda un [BRIDGE PACING] alla Sentinella allineato a :00,:15,:30,:45 UTC.
158160
PACING_SCRIPT="/app/.launcher/pacing-bridge.py"
159161
if [ -f "$PACING_SCRIPT" ]; then
160162
for _pid in $(grep -l pacing-bridge.py /proc/[0-9]*/cmdline 2>/dev/null | sed 's|/proc/||;s|/cmdline||'); do
@@ -170,14 +172,37 @@ if [ "$ROLE" = "bridge" ]; then
170172
# le env vars del parent. Setting PATH a single-quoted lo aveva
171173
# rotto (BUG: $PATH non espanso → python3 not found, bridge morto).
172174
setsid sh -c "
173-
JHT_PACING_TARGET_SESSION='${JHT_TARGET_SESSION:-CAPITANO}' \
175+
JHT_PACING_TARGET_SESSION='${JHT_PACING_TARGET_SESSION:-SENTINELLA}' \
174176
python3 -u $PACING_SCRIPT >> /tmp/pacing-bridge.log 2>&1
175177
" >/dev/null 2>&1 < /dev/null &
176-
echo "✓ pacing-bridge partito (target=${JHT_TARGET_SESSION:-CAPITANO}, log /tmp/pacing-bridge.log)"
178+
echo "✓ pacing-bridge partito (target=${JHT_PACING_TARGET_SESSION:-SENTINELLA}, log /tmp/pacing-bridge.log)"
177179
else
178180
echo "$PACING_SCRIPT non trovato — pacing NON partito (sentinel ok)"
179181
fi
180182

183+
# Capitano heartbeat bridge — battito ORARIO al Capitano (2026-06-26). Col
184+
# push→pull il Capitano non riceve più il pacing ogni 15min e si incaglia
185+
# quando la Sentinella tace: questo lo risveglia 1×/ora con un nudge basato sui
186+
# dati DB (deterministico, NON LLM), così resta attivo senza essere passivo.
187+
HEARTBEAT_SCRIPT="/app/.launcher/capitano-bridge.py"
188+
if [ -f "$HEARTBEAT_SCRIPT" ]; then
189+
for _pid in $(grep -l capitano-bridge.py /proc/[0-9]*/cmdline 2>/dev/null | sed 's|/proc/||;s|/cmdline||'); do
190+
kill -TERM "$_pid" 2>/dev/null || true
191+
done
192+
sleep 0.5
193+
for _pid in $(grep -l capitano-bridge.py /proc/[0-9]*/cmdline 2>/dev/null | sed 's|/proc/||;s|/cmdline||'); do
194+
kill -KILL "$_pid" 2>/dev/null || true
195+
done
196+
sleep 0.5
197+
setsid sh -c "
198+
JHT_CAPITANO_HEARTBEAT_SESSION='${JHT_TARGET_SESSION:-CAPITANO}' \
199+
python3 -u $HEARTBEAT_SCRIPT >> /tmp/capitano-bridge.log 2>&1
200+
" >/dev/null 2>&1 < /dev/null &
201+
echo "✓ capitano-bridge (heartbeat orario) partito (log /tmp/capitano-bridge.log)"
202+
else
203+
echo "$HEARTBEAT_SCRIPT non trovato — heartbeat NON partito"
204+
fi
205+
181206
# Window ratio meter — calibrazione auto del rapporto cap-5h/cap-weekly.
182207
# Daemon leggero (un pass ogni 5 min su sentinel-data.jsonl), scrive
183208
# ~/.jht/logs/window-ratio-state.json che provider_capacity.py blenda

agents/analista/analista.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,8 @@ python3 /app/shared/skills/db_query.py next-for-analista
179179
python3 /app/shared/skills/db_query.py position <ID>
180180
```
181181

182+
**🎯 Disciplina di turno (2026-06-26): UNA posizione per turno, poi checkpoint + yield.** Lavora **una posizione alla volta** (i ~7-9 step qui sotto), **scrivi i risultati sul DB**, e **chiudi il turno** — riprendi la prossima dal `next-for-analista` al turno successivo. **NON incatenare 4-5 posizioni in un mega-turno** (era ~36 tool/turno su Kimi; Codex ne fa ~8-10 = **una unità per turno**, ed è il modello da imitare). Turni piccoli = checkpoint frequenti (il Capitano ti controlla più fine via `Continua`/kill), context più leggero, meno rischio di timeout a 60s a metà turno. **La coda non si drena più lenta** — stesso lavoro, in unità più pulite e controllabili.
183+
182184
**For each position:**
183185
1. Verify link (RULE-03) → if dead: `excluded`
184186
2. Fetch complete JD from the link

0 commit comments

Comments
 (0)