Author SHA1 Message Date
nanobot 1a05d82453 fix(deploy): bypass proxy in health checks
Loopback health checks inherited http_proxy from /etc/environment and
were routed through v2rayA, failing spuriously (2026-08-28 incident).
2026-09-03 09:29:44 +08:00
nanobot 417e2f19d5 feat: add deploy/rollback supervisor script
Test Suite / Detect changes (push) Has been cancelled
Test Suite / Python (Windows, 3.14) (push) Has been cancelled
Test Suite / Python (minimum, 3.11) (push) Has been cancelled
Test Suite / Python (latest, 3.14 + coverage) (push) Has been cancelled
Test Suite / webui (push) Has been cancelled
Test Suite / docker (push) Has been cancelled
Git-checkout-based deployment with health checks and automatic rollback.
Usage: deploy.sh <commit> | deploy.sh rollback | deploy.sh status
2026-07-27 21:57:34 +08:00
nanobot 99b86a22c8 feat: recover stale sessions on gateway startup
Test Suite / Detect changes (push) Has been cancelled
Test Suite / Python (Windows, 3.14) (push) Has been cancelled
Test Suite / Python (minimum, 3.11) (push) Has been cancelled
Test Suite / Python (latest, 3.14 + coverage) (push) Has been cancelled
Test Suite / webui (push) Has been cancelled
Test Suite / docker (push) Has been cancelled
Scan all session files on startup and recover any with stale turn state
(pending_user_turn or runtime_checkpoint metadata) left from a previous
crash or restart. Previously this only happened lazily when a new message
arrived for the affected session.

- Add SessionManager.list_session_keys() to enumerate session keys from disk
- Add AgentLoop.recover_stale_sessions() to scan and recover all sessions
- Call recover_stale_sessions() during gateway startup in _run_gateway
2026-07-27 21:27:29 +08:00
4 changed files with 242 additions and 0 deletions
+31
View File
@@ -2038,3 +2038,34 @@ class AgentLoop:
finally: finally:
await self._runtime_events().run_status_changed(msg, session_key, "idle") await self._runtime_events().run_status_changed(msg, session_key, "idle")
self._runtime_events().clear_turn(session_key) self._runtime_events().clear_turn(session_key)
def recover_stale_sessions(self) -> int:
"""Scan all sessions on startup and recover any with stale turn state.
Returns the number of sessions that were recovered.
"""
recovered = 0
for key in self.sessions.list_session_keys():
try:
session = self.sessions.get_or_create(key)
changed = False
if self._restore_runtime_checkpoint(session):
changed = True
if self._restore_pending_user_turn(session):
changed = True
if changed:
self.sessions.save(session)
recovered += 1
logger.info(
"Recovered stale session {} on startup",
key,
)
except Exception:
logger.debug(
"Could not recover stale session {}",
key,
exc_info=True,
)
if recovered:
logger.info("Recovered {} stale session(s) on startup", recovered)
return recovered
+4
View File
@@ -1744,6 +1744,10 @@ def _run_gateway(
local_trigger_store=trigger_store, local_trigger_store=trigger_store,
hook_factories=[create_file_edit_activity_hook], hook_factories=[create_file_edit_activity_hook],
) )
# Recover any sessions left in a stale state from a previous crash/restart.
agent.recover_stale_sessions()
webui_turn_coordinator = WebuiTurnCoordinator( webui_turn_coordinator = WebuiTurnCoordinator(
bus=bus, bus=bus,
sessions=session_manager, sessions=session_manager,
+20
View File
@@ -965,3 +965,23 @@ class SessionManager:
) )
continue continue
return sorted(sessions, key=lambda x: x.get("updated_at", ""), reverse=True) return sorted(sessions, key=lambda x: x.get("updated_at", ""), reverse=True)
def list_session_keys(self) -> list[str]:
"""List all session keys from disk without loading full sessions."""
keys: list[str] = []
for path in self.sessions_dir.glob("*.jsonl"):
storage_key = self._session_key_from_path(path)
if storage_key is None:
continue
try:
with open(path, encoding="utf-8") as f:
first_line = f.readline().strip()
if first_line:
data = json.loads(first_line)
if isinstance(data, dict) and data.get("_type") == "metadata":
keys.append(data.get("key") or storage_key)
else:
keys.append(storage_key)
except (OSError, _SESSION_DATA_ERRORS):
continue
return keys
+187
View File
@@ -0,0 +1,187 @@
#!/usr/bin/env bash
# nanobot deploy/rollback supervisor
# Manages git-checkout-based deployments with health checks and automatic rollback.
#
# Usage:
# deploy.sh <commit-ish> Deploy a candidate commit
# deploy.sh rollback Rollback to last known-good
# deploy.sh status Show current deployment state
set -euo pipefail
CHECKOUT="/home/claw/nanobot-src"
STATE_FILE="/home/claw/.nanobot/deploy-state.json"
HEALTH_URL="http://127.0.0.1:18790/health"
STABILIZE_SEC=8
HEALTH_TIMEOUT_SEC=30
SERVICE="nanobot"
log() { echo "[deploy] $(date '+%H:%M:%S') $*"; }
# ---- helpers ----
current_commit() { git -C "$CHECKOUT" rev-parse HEAD; }
short_hash() { echo "${1:0:8}"; }
read_state() {
if [[ -f "$STATE_FILE" ]]; then
python3 -c "import json; d=json.load(open('$STATE_FILE')); print(d.get('known_good',''))"
else
echo ""
fi
}
write_state() {
local known_good="$1"
python3 -c "
import json, sys
d = {}
try: d = json.load(open('$STATE_FILE'))
except: pass
d['known_good'] = '$known_good'
d['last_updated'] = '$(date -Iseconds)'
json.dump(d, open('$STATE_FILE', 'w'), indent=2)
"
}
restart_service() {
export XDG_RUNTIME_DIR=/run/user/1000
systemctl --user restart "$SERVICE"
}
service_active() {
export XDG_RUNTIME_DIR=/run/user/1000
systemctl --user is-active --quiet "$SERVICE"
}
health_check() {
# Returns 0 if healthy, 1 if not
local deadline=$((SECONDS + HEALTH_TIMEOUT_SEC))
while (( SECONDS < deadline )); do
if curl -sf --noproxy '*' --max-time 3 "$HEALTH_URL" >/dev/null 2>&1; then
return 0
fi
sleep 1
done
return 1
}
# ---- commands ----
cmd_status() {
echo "Checkout: $CHECKOUT"
echo "Current commit: $(current_commit) ($(short_hash "$(current_commit)"))"
echo "Known-good: $(read_state)"
echo "Service: $(service_active && echo 'active' || echo 'inactive')"
}
cmd_deploy() {
local candidate="$1"
local known_good
known_good=$(read_state)
cd "$CHECKOUT"
# Record current as known-good if not set
if [[ -z "$known_good" ]]; then
known_good=$(current_commit)
write_state "$known_good"
log "Initial known-good: $(short_hash "$known_good")"
fi
# Check candidate exists
if ! git cat-file -e "$candidate^{commit}" 2>/dev/null; then
log "ERROR: candidate '$candidate' is not a valid commit"
return 1
fi
local candidate_hash
candidate_hash=$(git rev-parse "$candidate")
if [[ "$candidate_hash" == "$(current_commit)" ]]; then
log "Already at candidate $(short_hash "$candidate_hash"), just restarting"
else
log "Deploying candidate: $(short_hash "$candidate_hash")"
log "Known-good: $(short_hash "$known_good")"
git checkout "$candidate_hash"
fi
log "Restarting service..."
restart_service
log "Waiting ${STABILIZE_SEC}s for stabilization..."
sleep "$STABILIZE_SEC"
if ! service_active; then
log "ERROR: service not active after restart"
cmd_rollback_internal "$known_good" "$candidate_hash"
return 1
fi
log "Running health check..."
if health_check; then
log "Health check PASSED"
write_state "$candidate_hash"
log "Promoted $(short_hash "$candidate_hash") to known-good"
else
log "ERROR: health check FAILED"
cmd_rollback_internal "$known_good" "$candidate_hash"
return 1
fi
}
cmd_rollback_internal() {
local target="$1"
local failed="${2:-unknown}"
log "ROLLING BACK to $(short_hash "$target")"
cd "$CHECKOUT"
git checkout "$target"
restart_service
sleep "$STABILIZE_SEC"
if service_active && health_check; then
log "Rollback successful, service healthy"
# Write failure report
cat > "/home/claw/.nanobot/deploy-failure.json" <<EOF
{
"failed_candidate": "$failed",
"known_good": "$target",
"timestamp": "$(date -Iseconds)",
"rollback_successful": true
}
EOF
else
log "CRITICAL: rollback also failed! Manual intervention required."
cat > "/home/claw/.nanobot/deploy-failure.json" <<EOF
{
"failed_candidate": "$failed",
"known_good": "$target",
"timestamp": "$(date -Iseconds)",
"rollback_successful": false
}
EOF
return 1
fi
}
cmd_rollback() {
local known_good
known_good=$(read_state)
if [[ -z "$known_good" ]]; then
log "No known-good recorded, cannot rollback"
return 1
fi
cmd_rollback_internal "$known_good" "$(current_commit)"
}
# ---- main ----
case "${1:-status}" in
status) cmd_status ;;
rollback) cmd_rollback ;;
*) cmd_deploy "$1" ;;
esac