Skip to content

Commit ee159ae

Browse files
committed
merge: dev3 → master — resilience: respawn automatico bridge + canary processi 4 layer + process_health daemon dashboard + guardia TimeoutExpired tmux-send
2 parents 1425239 + c1b2368 commit ee159ae

9 files changed

Lines changed: 476 additions & 12 deletions

File tree

.launcher/agent-watchdog.sh

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@
2727

2828
set -u
2929

30+
# jht-tmux-send vive in /app/agents/_tools (come in start-agent.sh): serve per
31+
# l'escalation al Capitano in maybe_respawn_bridges. Best-effort se assente.
32+
export PATH="/app/agents/_tools:${PATH}"
33+
3034
JHT_HOME="${JHT_HOME:-/jht_home}"
3135
CONFIG="$JHT_HOME/jht.config.json"
3236
JHT_BIN="/app/cli/bin/jht.js"
@@ -37,6 +41,23 @@ AGENTS=(assistente capitano mentor sentinella)
3741
# il context window accumulato. Refresh deterministico, near-stateless.
3842
SENTINELLA_MAX_CTX_AGE_H="${JHT_SENTINELLA_MAX_CTX_AGE_H:-24}"
3943

44+
# ── Bridge suite supervision (2026-06-27) ──────────────────────────────
45+
# I bridge/daemon ausiliari sono lanciati `setsid` detached da start-agent.sh
46+
# → NON sono figli di pid1, quindi il respawn-on-crash di pid1 NON li copre.
47+
# Se uno muore (crash da eccezione, OOM-kill) resta giù finché non riparte il
48+
# container — è il buco che ha lasciato andris cieco sull'usage per 8h il
49+
# 2026-06-27. Qui li risorvegliamo a ogni tick. **Anti-flap** (lezione del V4
50+
# restart-loop, per cui il self-restart del bridge fu RIMOSSO): oltre un cap di
51+
# respawn in finestra, NON rispawna più e ESCALA al Capitano — niente crash-loop.
52+
# Vedi docs/internal/2026-06-27-andris-sentinel-bridge-crash.md.
53+
BRIDGE_STATE_DIR="$JHT_HOME/logs"
54+
BRIDGE_FLAP_WINDOW_SEC="${JHT_BRIDGE_FLAP_WINDOW_SEC:-600}" # 10 min
55+
BRIDGE_FLAP_CAP="${JHT_BRIDGE_FLAP_CAP:-3}" # max respawn/finestra
56+
BRIDGE_ESCALATE_COOLDOWN_SEC="${JHT_BRIDGE_ESCALATE_COOLDOWN_SEC:-3600}"
57+
# La liveness dei processi la calcola shared/skills/process_health.py (in Python,
58+
# senza self-match) — stessa fonte di verità del Mantenitore. La suite detached
59+
# riparabile da un solo `start-agent.sh bridge` è definita lì (gruppo bridge-suite).
60+
4061
mkdir -p "$(dirname "$LOG")"
4162

4263
log() {
@@ -141,7 +162,101 @@ maybe_refresh_sentinella() {
141162
fi
142163
}
143164

144-
log "watchdog start · interval=${INTERVAL_SEC}s · agents=${AGENTS[*]} · sentinella_max_ctx_age=${SENTINELLA_MAX_CTX_AGE_H}h"
165+
bridge_flap_ok() {
166+
# 0 se sotto il cap di respawn nella finestra, 1 se il cap è superato.
167+
local key="$1" f="$BRIDGE_STATE_DIR/bridge-flap-$1" now cutoff cnt
168+
now=$(date -u +%s); cutoff=$((now - BRIDGE_FLAP_WINDOW_SEC))
169+
[ -f "$f" ] || return 0
170+
cnt=$(awk -v c="$cutoff" '$1+0>=c' "$f" 2>/dev/null | wc -l | tr -d ' ')
171+
[ "${cnt:-0}" -lt "$BRIDGE_FLAP_CAP" ]
172+
}
173+
174+
bridge_flap_record() {
175+
# appende 'now' e pota i timestamp fuori finestra (rolling window).
176+
local key="$1" f="$BRIDGE_STATE_DIR/bridge-flap-$1" now cutoff
177+
now=$(date -u +%s); cutoff=$((now - BRIDGE_FLAP_WINDOW_SEC))
178+
{ [ -f "$f" ] && awk -v c="$cutoff" '$1+0>=c' "$f" 2>/dev/null; echo "$now"; } \
179+
> "$f.tmp" 2>/dev/null && mv "$f.tmp" "$f" 2>/dev/null || true
180+
}
181+
182+
bridge_escalate() {
183+
# avvisa il Capitano UNA volta per finestra di cooldown (no spam), poi tace.
184+
local what="$1" now ef last
185+
now=$(date -u +%s)
186+
ef="$BRIDGE_STATE_DIR/bridge-escalate.ts"
187+
if [ -f "$ef" ]; then
188+
last=$(cat "$ef" 2>/dev/null || echo 0)
189+
[ $((now - last)) -lt "$BRIDGE_ESCALATE_COOLDOWN_SEC" ] && return 0
190+
fi
191+
echo "$now" > "$ef" 2>/dev/null || true
192+
log "bridge-watchdog: FLAP CAP superato ($what) — STOP respawn, escalo al Capitano"
193+
jht-tmux-send CAPITANO "[WATCHDOG] $what continua a morire (>${BRIDGE_FLAP_CAP} respawn in $((BRIDGE_FLAP_WINDOW_SEC/60))min). Ho FERMATO il respawn automatico per evitare un crash-loop. Serve diagnosi manuale: controlla /tmp/*-bridge.log. Il Mantenitore farà comunque un canary completo al prossimo sweep." >/dev/null 2>&1 || true
194+
}
195+
196+
tg_bots_configured() {
197+
# vero se almeno un bot Telegram ha un token (channels.telegram.bots.<role>).
198+
python3 - "$CONFIG" 2>/dev/null <<'PYEOF'
199+
import json, sys
200+
try:
201+
d = json.load(open(sys.argv[1]))
202+
except Exception:
203+
sys.exit(1)
204+
bots = (d.get("channels", {}) or {}).get("telegram", {}).get("bots", {}) or {}
205+
sys.exit(0 if any((b or {}).get("bot_token") for b in bots.values()) else 1)
206+
PYEOF
207+
}
208+
209+
maybe_respawn_bridges() {
210+
# Liveness via process_health.py (legge /proc in PYTHON → NIENTE self-match,
211+
# a differenza di `grep MARKER /proc/*/cmdline` che trova il proprio argv e
212+
# riporterebbe SEMPRE "vivo"). Unica fonte di verità, condivisa col Mantenitore
213+
# (step 0 di maintainer-sweep). In steady-state: 1 call, tutti vivi, zero azione.
214+
local plan PROC_DEAD_BRIDGE_SUITE="" PROC_DEAD_DEEP="" PROC_TG_ALIVE=0 PROC_ALL_OK=1
215+
# NB: process_health.py esce 1 PROPRIO quando c'è un morto (è il caso che ci
216+
# interessa) → NON gatare su `|| return` sull'exit code, sarebbe un no-op
217+
# esattamente quando serve agire. Catturiamo l'output sempre; skip solo se vuoto
218+
# (python assente/errore reale).
219+
plan=$(python3 /app/shared/skills/process_health.py summary --shell 2>/dev/null)
220+
[ -z "$plan" ] && return 0
221+
eval "$plan" 2>/dev/null || return 0
222+
[ "${PROC_ALL_OK:-1}" = "1" ] && [ -z "$PROC_DEAD_BRIDGE_SUITE" ] && [ "${PROC_TG_ALIVE:-9}" -ge 3 ] && return 0
223+
224+
# (1) bridge-suite morti → un solo `start-agent.sh bridge` li rispawna tutti
225+
# (idempotente kill+respawn). Anti-flap: oltre il cap, escala invece di loopare.
226+
if [ -n "$PROC_DEAD_BRIDGE_SUITE" ]; then
227+
if bridge_flap_ok bridge; then
228+
log "bridge-watchdog: suite incompleta (morti: $PROC_DEAD_BRIDGE_SUITE) — respawn via start-agent.sh bridge"
229+
JHT_HOME="$JHT_HOME" bash /app/.launcher/start-agent.sh bridge >>"$LOG" 2>&1 \
230+
|| log "bridge-watchdog: respawn bridge FAIL (rc=$?)"
231+
bridge_flap_record bridge
232+
else
233+
bridge_escalate "suite bridge (morti: $PROC_DEAD_BRIDGE_SUITE)"
234+
fi
235+
fi
236+
237+
# (2) tg-bridge: canale Telegram OPZIONALE → respawn solo se i bot sono
238+
# configurati e mancano istanze (attese >=3 process: wrapper+python ×3).
239+
if tg_bots_configured && [ "${PROC_TG_ALIVE:-0}" -lt 3 ]; then
240+
if bridge_flap_ok tg-bridge; then
241+
log "bridge-watchdog: tg-bridge incompleto (${PROC_TG_ALIVE}/3 process) — respawn via start-agent.sh tg-bridge"
242+
JHT_HOME="$JHT_HOME" bash /app/.launcher/start-agent.sh tg-bridge >>"$LOG" 2>&1 \
243+
|| log "bridge-watchdog: respawn tg-bridge FAIL (rc=$?)"
244+
bridge_flap_record tg-bridge
245+
else
246+
bridge_escalate "tg-bridge (${PROC_TG_ALIVE}/3)"
247+
fi
248+
fi
249+
250+
# (3) Process "profondi" morti (doctor-watchdog/auto-report/cloud-daemon/pid1):
251+
# dovrebbe rispawnarli pid1. Se restano morti è un problema più serio →
252+
# ESCALA (NON tentare il respawn da qui: li orfaneremmo). agent-watchdog
253+
# non comparirà mai qui (è il processo che gira questo check).
254+
if [ -n "$PROC_DEAD_DEEP" ]; then
255+
bridge_escalate "process pid1-managed morti: $PROC_DEAD_DEEP"
256+
fi
257+
}
258+
259+
log "watchdog start · interval=${INTERVAL_SEC}s · agents=${AGENTS[*]} · sentinella_max_ctx_age=${SENTINELLA_MAX_CTX_AGE_H}h · bridge_supervision=on (flap_cap=${BRIDGE_FLAP_CAP}/$((BRIDGE_FLAP_WINDOW_SEC/60))min)"
145260

146261
# Loop principale: gate sulla config (può non essere ancora pronta al
147262
# primo boot del container — il wizard la scrive post-pairing). Sleep
@@ -182,6 +297,10 @@ while true; do
182297
for role in "${AGENTS[@]}"; do
183298
ensure_agent "$role"
184299
done
300+
# Bridge/daemon detached (sentinel/pacing/capitano-bridge/window-ratio/
301+
# codex-auth-healer + tg-bridge): respawn se morti, con anti-flap. Sono
302+
# fuori dal respawn-on-crash di pid1 (setsid), questo è il loro recovery.
303+
maybe_respawn_bridges
185304
else
186305
# Soft log: config non pronta = wizard non ancora finito. Aspettiamo
187306
# silenziosamente, niente spam.

.launcher/pacing-bridge.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1338,4 +1338,22 @@ def main():
13381338

13391339

13401340
if __name__ == "__main__":
1341-
main()
1341+
# Supervisore in-process (difesa in profondità, gemello del sentinel-bridge):
1342+
# il loop ha già un try/except per-tick, ma un'eccezione FUORI dal tick (es.
1343+
# next_quarter/sleep) ucciderebbe comunque il processo, senza recovery (setsid
1344+
# detached). Qui qualsiasi eccezione → log + ri-entro in main(). Con --once
1345+
# main() ritorna subito → break. Vedi 2026-06-27-andris-sentinel-bridge-crash.md.
1346+
import time as _time
1347+
import traceback as _tb
1348+
while True:
1349+
try:
1350+
main()
1351+
break
1352+
except KeyboardInterrupt:
1353+
print("\n[pacing-bridge] interrotto.", file=sys.stderr)
1354+
break
1355+
except Exception as _e: # noqa: BLE001 — catch-all VOLUTO
1356+
print(f"[pacing-bridge] FATAL nel loop: {_e} — riavvio in 5s",
1357+
file=sys.stderr, flush=True)
1358+
_tb.print_exc()
1359+
_time.sleep(5)

.launcher/sentinel-bridge.py

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -427,7 +427,16 @@ def session_exists(s):
427427

428428

429429
def jht_tmux_send(session, text):
430-
return subprocess.run(["jht-tmux-send", session, text], capture_output=True, timeout=15).returncode == 0
430+
# Difesa: un tmux-send che si blocca (pane occupato) NON deve mai abbattere il
431+
# bridge. Senza questa guardia, TimeoutExpired propagava fuori dal while-loop di
432+
# main() (l'unico handler esterno è KeyboardInterrupt) → bridge morto in silenzio,
433+
# zero auto-recovery (setsid detached, fuori dal respawn di pid1). Vedi postmortem
434+
# docs/internal/2026-06-27-andris-sentinel-bridge-crash.md. Degrada a "tick saltato".
435+
try:
436+
return subprocess.run(["jht-tmux-send", session, text], capture_output=True, timeout=15).returncode == 0
437+
except (subprocess.TimeoutExpired, OSError) as e:
438+
print(f"[bridge V6] WARN jht_tmux_send({session}): {e}", file=sys.stderr)
439+
return False
431440

432441

433442
def _sample_vitals_and_maybe_alert():
@@ -1680,7 +1689,25 @@ def main():
16801689

16811690

16821691
if __name__ == "__main__":
1683-
try:
1684-
main()
1685-
except KeyboardInterrupt:
1686-
print("\n[bridge V6] interrotto.")
1692+
# Supervisore in-process (difesa in profondità): una QUALSIASI eccezione non
1693+
# gestita nel loop di main() NON deve uccidere il bridge. Era il bug del
1694+
# 2026-06-27: TimeoutExpired su jht_tmux_send propagava fuori dal while-loop
1695+
# → processo morto e ZERO recovery (setsid detached, fuori dal respawn di
1696+
# pid1). Qui la cattura, logga e RI-ENTRA in main(). Layer complementari:
1697+
# (a) la guardia in jht_tmux_send degrada il caso noto a "tick saltato";
1698+
# (b) l'agent-watchdog (maybe_respawn_bridges) respawna il processo se muore
1699+
# del tutto (OOM/kill); (c) il Mantenitore fa il canary completo 1×/dì.
1700+
# Vedi docs/internal/2026-06-27-andris-sentinel-bridge-crash.md.
1701+
import time as _time
1702+
import traceback as _tb
1703+
while True:
1704+
try:
1705+
main()
1706+
break # uscita normale (main() è un loop infinito → non dovrebbe capitare)
1707+
except KeyboardInterrupt:
1708+
print("\n[bridge V6] interrotto.")
1709+
break
1710+
except Exception as _e: # noqa: BLE001 — catch-all VOLUTO: niente morte silenziosa
1711+
print(f"[bridge V6] FATAL nel loop: {_e} — riavvio in 5s", file=sys.stderr)
1712+
_tb.print_exc()
1713+
_time.sleep(5)

BACKLOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ Versione: `package.json` `v0.1.17` (production ferma a `v0.1.12` dopo blocco Tur
159159
- **✅ DONE 2026-06-21 (commit `1530430c7` + `32d28df48`)** — affordance UI read-only sul cloud (d3 di `[JHT-WEB-READONLY]`), a livello PAGINA/SEZIONE (no `if` sparsi). **(a) Pagine intere desktop-only**: guard unico nel `(protected)/layout.tsx` → su `isCloudDeploy()` redirect a `/dashboard` per `settings`/`credentials`/`secrets`/`channels`/`providers`/`integrations`/`cron`/`backup`/`setup`/`cli-link` (eccezione `settings/cloud-sync` = sync-infra, resta); `UserMenu` nasconde i link Impostazioni/Backup. **(b) Sezioni-controllo in pagine miste** (la pagina resta, sparisce il controllo): `/team` start/stop + azioni per-agente; `AgentInteraction` (chat+terminale di analista/critico/scrittore/scout/scorer) → `null` su cloud + stop polling; chat composer di `/team/capitano` e `/team/assistente`; `/profile``ProfileEditButton`+`ProfileAssistantFab` nascosti = view-only (export resta). La sicurezza è comunque a monte (route 403 via `requireLocalWrite`+`isCloudDeploy`); questo toglie i vicoli ciechi.
160160
- **⬜ Rifinitura minore residua** — bottoni secondari sulle pagine miste (pulisci-chat, apri-terminale, toggle-terminale in capitano/assistente): innocui sul cloud (agiscono su dati locali assenti) ma volendo si nascondono con lo stesso pattern.
161161
- ⬜ **`[JHT-CLOUD-INTERACTIVE-RETIRE]` 🟡 (= fix di scala definitivo)** — freeze/ritiro del **path cloud interattivo** ridondante una volta che l'interazione è desktop+tunnel: bus `team_state` control + reconciler, lane `user_to_agent_messages` + `user-messages-poller`, `team_commands` legacy. **NB:** è qui che il costo Vercel per-utente va a ~0 (mitigato nel frattempo dal poll adattivo, `[JHT-DATA-SYNC]` (4), ma non azzerato): solo togliendo i poller di controllo resta **un solo daemon di sync** per VPS. **🟡 In larga parte FATTO 2026-06-21 (dev4, commit `ce96b44eb`)**: **scoperta** che il controllo Start/Stop è retto SOLO dal reconciler (la dashboard scrive `should_run`, nessun path diretto) → spegnerlo romperebbe il controllo. Fix corretto = **fold nel daemon**: `team-state-reconciler` → `reconcileOnce()` chiamata dal cloud daemon ogni tick (riusa `reconcile()`/`applyAction()`: `.team-halted.flag` + heartbeat preservati); poller standalone **non parte più**. `user-messages-poller` **ritirato** (chat web=solo-desktop). `team-commands-poller` + `file-bridge` **restano** (adattivi) finché i loro feeder cloud non spariscono (sentinella web in dismissione lato collega). Entrambi i ritirati ri-attivabili con `JHT_CLOUD_CONTROL_POLLERS=1`. **Effetto**: da 3 poller a 5s/≤30s per-utente → 1 daemon a 60s (+team-commands transitorio). Latenza controllo ≤60s. ⚠️ Caveat: niente più claim single-team (edge multi-VPS/utente). **Test runtime utente** (controllo via dashboard+daemon). Residuo: ritiro `team-commands` quando il feeder web è rimosso; eventuale freeze/delete delle route cloud `team_commands`/`team-state` browser-ramp (lane web/collega). **⚠️ Eccezione (rivisto 2026-06-20):** la **corsia richieste async** (`position_tickets`, `position_feedback`, flag `*_requested` + `pull-desired-state`) **NON si ritira** — non è il bus real-time, è un'event-queue che la VPS pulla con calma (vedi `[JHT-DATA-SYNC]`). **Resta** il push dati one-way + `jht cloud restore` + dashboard read-only. ⚠️ *Ribalta codice shippato 23–31 maggio: scelta freeze-vs-delete deliberata, pezzo per pezzo.*
162+
- ⬜ **`[JHT-REALTIME-SCALE]` 🟢 (robustezza/scaling daemon event-driven, NON urgente)** — il sync event-driven via Supabase Realtime è live+validato su andris (dietro flag `JHT_REALTIME_SYNC`, vedi `docs/internal/2026-06-26-sync-status-report.md`). Degrada con grazia (col Realtime giù → floor = paracadute ~56 q/h/utente, -94%, nessun dato perso), quindi questi sono affinamenti di scala da fare crescendo a molti clienti, non blocchi: **(1) monitor del rate di riconnessione** per-VPS (conteggio `CHANNEL_ERROR`/`SUBSCRIBED` + alert); **(2) tetto connessioni Realtime Supabase** (~500 concorrenti su Pro = muro di scala vero → bump tier/sharding); **(3) thundering herd** al riavvio del server Realtime (N socket riconnettono insieme → jitter/backoff extra); **(4) unificare l'auth** REST-diretto+websocket (oggi 2 sessioni sullo stesso refresh_token → race di rotazione possibile a scala); **(5) paracadute regolabile** (`JHT_PARACHUTE_SEC`) come leva se il Realtime è inaffidabile. Vedi `[JHT-DATA-SYNC]` / `[JHT-DAEMON-SUPABASE-DIRECT]`.
162163
-**`[JHT-SETUP-LOCAL-FIRST]` 🟢** — rielevare il **Local PC** a path di prima classe ("il team è tuo, accendi/spegni quando vuoi"); allinea la copy execution-mode (oggi "not recommended for daily machines"). Si incastra col Dedicated computer mode.
163164

164165
**Sequenza proposta:** I `[TELEGRAM-OPTIONAL]` → II `[DATA-SYNC]` → III `[WEB-READONLY]` → IV `[DASHBOARD-SPLIT]`/`[DESKTOP-COCKPIT]` → V `[VPS-TUNNEL]` → VI `[CLOUD-INTERACTIVE-RETIRE]`. I+II scorporabili subito; V porta il valore "VPS come locale". **Decisioni aperte per l'utente** elencate nel design doc § "Decisioni aperte".

0 commit comments

Comments
 (0)