fix: Agent survives slow Hub on large syncs (25-product chunks, 120s timeout, retry) and keeps heartbeating during jobs; 1.1.4

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
gti
2026-09-24 09:33:54 -03:00
co-authored by Claude Sonnet 5
parent d1104589cc
commit e40e9500a4
19 changed files with 75 additions and 26 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "gti-fiscal-agent" name = "gti-fiscal-agent"
version = "1.1.3" version = "1.1.4"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = ["httpx>=0.27,<1"] dependencies = ["httpx>=0.27,<1"]
+25 -4
View File
@@ -20,6 +20,7 @@ import logging.handlers
import os import os
import signal import signal
import sys import sys
import threading
import time import time
from pathlib import Path from pathlib import Path
@@ -102,7 +103,7 @@ class Config:
# (A legacy AGENT_JOBS_ENABLED line in an old agent.env is deliberately ignored.) # (A legacy AGENT_JOBS_ENABLED line in an old agent.env is deliberately ignored.)
self.agent_jobs_enabled = True self.agent_jobs_enabled = True
self.poll_interval_seconds = float(env.get("POLL_INTERVAL_SECONDS", "5")) self.poll_interval_seconds = float(env.get("POLL_INTERVAL_SECONDS", "5"))
self.agent_version = env.get("AGENT_VERSION", "1.1.3") self.agent_version = env.get("AGENT_VERSION", "1.1.4")
self.schema_fingerprint = env.get("SCHEMA_FINGERPRINT", REFERENCE_SCHEMA_V1) self.schema_fingerprint = env.get("SCHEMA_FINGERPRINT", REFERENCE_SCHEMA_V1)
self.state_db = env.get("AGENT_STATE_DB", str(AGENT_ROOT / "agent_state.sqlite")) self.state_db = env.get("AGENT_STATE_DB", str(AGENT_ROOT / "agent_state.sqlite"))
# Pilot write authorization: both must be explicitly set by the installer for # Pilot write authorization: both must be explicitly set by the installer for
@@ -239,7 +240,7 @@ def execute_job(job, config, executor, state, env):
"coverage": coverage, "coverage": coverage,
"operational": operational, "operational": operational,
"fiscal_mode": getattr(config, "fiscal_mode", None), "fiscal_mode": getattr(config, "fiscal_mode", None),
"release_version": env.get("AGENT_VERSION") or "1.1.3", "release_version": env.get("AGENT_VERSION") or "1.1.4",
"products_total": len(products), "products_total": len(products),
"products_changed": len(to_process), "products_changed": len(to_process),
"products_processed": summary["processed"], "products_processed": summary["processed"],
@@ -268,6 +269,17 @@ def execute_job(job, config, executor, state, env):
return None, f"{type(error).__name__}: {error}" return None, f"{type(error).__name__}: {error}"
BUSY_HEARTBEAT_SECONDS = 20
def _busy_heartbeats(client, installation_id, config, job_id, done):
while not done.wait(BUSY_HEARTBEAT_SECONDS):
try:
client.heartbeat_metadata({"installation_id": installation_id, "status": "BUSY", "schema_fingerprint": config.schema_fingerprint, "agent_version": config.agent_version, "last_job_id": job_id})
except Exception: # noqa: BLE001 — Hub hiccup mid-job must not affect the job
pass
def resolve_installation_id(config, env, state, client): def resolve_installation_id(config, env, state, client):
"""Recover and persist the Hub-bound identity; never invent one for an Agent.""" """Recover and persist the Hub-bound identity; never invent one for an Agent."""
persisted = state.stored_installation_id() persisted = state.stored_installation_id()
@@ -362,7 +374,16 @@ def run(config, env, *, max_iterations=None, stopper=None):
except Exception: except Exception:
pass # best-effort; the job itself still proceeds pass # best-effort; the job itself still proceeds
result_summary, error = execute_job(job, config, executor, state, env) # A full sync can take minutes: keep the heartbeat alive (BUSY) from a side thread so the
# panel never shows a working Agent as OFFLINE. The side thread only talks to the Hub
# (no SQLite/Firebird), so it cannot interfere with the job.
done = threading.Event()
beat = threading.Thread(target=_busy_heartbeats, args=(client, installation_id, config, last_job_id, done), daemon=True)
beat.start()
try:
result_summary, error = execute_job(job, config, executor, state, env)
finally:
done.set()
try: try:
if error is None: if error is None:
client.complete_job(job.get("job_id"), result_summary) client.complete_job(job.get("job_id"), result_summary)
@@ -377,7 +398,7 @@ def run(config, env, *, max_iterations=None, stopper=None):
def enforce_startup_preflight(env): def enforce_startup_preflight(env):
"""Full read-only preflight at startup (Hub, auth, Firebird, capabilities, company context, guards). Fail closed.""" """Full read-only preflight at startup (Hub, auth, Firebird, capabilities, company context, guards). Fail closed."""
from gti_fiscal_agent.preflight import run_preflight from gti_fiscal_agent.preflight import run_preflight
report = run_preflight(env, agent_version=env.get("AGENT_VERSION") or "1.1.3") report = run_preflight(env, agent_version=env.get("AGENT_VERSION") or "1.1.4")
logger.info("startup preflight: %s", json.dumps({k: report[k] for k in ("PREFLIGHT", "blockers", "fiscal_mode", "effective_firebird_writes")}, ensure_ascii=False)) logger.info("startup preflight: %s", json.dumps({k: report[k] for k in ("PREFLIGHT", "blockers", "fiscal_mode", "effective_firebird_writes")}, ensure_ascii=False))
print(json.dumps({"PREFLIGHT": report["PREFLIGHT"], "blockers": report["blockers"]}, ensure_ascii=False)) print(json.dumps({"PREFLIGHT": report["PREFLIGHT"], "blockers": report["blockers"]}, ensure_ascii=False))
if report["PREFLIGHT"] != "PASS": if report["PREFLIGHT"] != "PASS":
+9 -2
View File
@@ -1,13 +1,20 @@
import httpx import httpx
class HubClient: class HubClient:
def __init__(self, url, token, chunk_size=100): self.url, self.token, self.chunk_size = url.rstrip("/"), token, chunk_size def __init__(self, url, token, chunk_size=25): self.url, self.token, self.chunk_size = url.rstrip("/"), token, chunk_size
def _headers(self): return {"Authorization": f"Bearer {self.token}"} def _headers(self): return {"Authorization": f"Bearer {self.token}"}
def classify_batch(self, products): def classify_batch(self, products):
results = [] results = []
for start in range(0, len(products), self.chunk_size): for start in range(0, len(products), self.chunk_size):
chunk = products[start:start + self.chunk_size] chunk = products[start:start + self.chunk_size]
response = httpx.post(f"{self.url}/v1/classify/batch", json={"products": chunk}, headers=self._headers(), timeout=60) # Small chunks + generous timeout + one retry: a large first sync must survive a
# slow Hub / reverse-proxy read timeout instead of failing the whole job.
for attempt in (1, 2):
try:
response = httpx.post(f"{self.url}/v1/classify/batch", json={"products": chunk}, headers=self._headers(), timeout=120)
break
except httpx.ReadTimeout:
if attempt == 2: raise
response.raise_for_status(); results.extend(response.json()["results"]) response.raise_for_status(); results.extend(response.json()["results"])
return results return results
def get_json(self, path): def get_json(self, path):
+1 -1
View File
@@ -9,7 +9,7 @@ import os
import sqlite3 import sqlite3
from pathlib import Path from pathlib import Path
AGENT_VERSION_DEFAULT = "1.1.3" AGENT_VERSION_DEFAULT = "1.1.4"
REQUIRED_ENV = ("GTI_HUB_URL", "AGENT_TOKEN", "FIREBIRD_DATABASE_PATH", "FB_USER", "FB_PASSWORD") REQUIRED_ENV = ("GTI_HUB_URL", "AGENT_TOKEN", "FIREBIRD_DATABASE_PATH", "FB_USER", "FB_PASSWORD")
REQUIRED_CAPABILITIES = ("supports_ncm", "supports_ipi", "supports_cest") REQUIRED_CAPABILITIES = ("supports_ncm", "supports_ipi", "supports_cest")
FISCAL_MODES = ("DRY_RUN", "AUTO_APPLY") FISCAL_MODES = ("DRY_RUN", "AUTO_APPLY")
+21
View File
@@ -1,6 +1,7 @@
"""Agent side of the zero-touch model: delta sync via fingerprint cache, read-only guarantee, """Agent side of the zero-touch model: delta sync via fingerprint cache, read-only guarantee,
blockers/operational summary in the job result, central pause, Firebird credential resolution.""" blockers/operational summary in the job result, central pause, Firebird credential resolution."""
import json import json
import time
import pytest import pytest
@@ -125,3 +126,23 @@ def test_central_resume_after_pause_makes_agent_poll_again(monkeypatch, tmp_path
monkeypatch.setattr(service.time, "sleep", lambda s: None) monkeypatch.setattr(service.time, "sleep", lambda s: None)
service.run(_config(agent_id="2", poll_interval_seconds=0, state_db=str(tmp_path / "a.db")), {"AGENT_ID": "2"}, stopper=stopper) service.run(_config(agent_id="2", poll_interval_seconds=0, state_db=str(tmp_path / "a.db")), {"AGENT_ID": "2"}, stopper=stopper)
assert calls["next_job"] == 1 # first cycle paused (no poll), second cycle resumed (polled) assert calls["next_job"] == 1 # first cycle paused (no poll), second cycle resumed (polled)
def test_heartbeats_keep_flowing_while_a_long_job_runs(monkeypatch, tmp_path):
beats = []
class Hub:
def identity(self): return {"agent_id": 2, "installation_id": "00000000-0000-4000-8000-000000000222"}
def heartbeat_metadata(self, payload): beats.append(payload["status"]); return {"processing_enabled": True}
def next_job(self): return {"job_id": 5, "kind": "INITIAL_SYNC", "agent_id": 2, "payload": {}}
def complete_job(self, job_id, summary): stopper.stop = True
def fail_job(self, job_id, error): stopper.stop = True
class Stopper: stop = False
stopper = Stopper()
monkeypatch.setattr(service, "BUSY_HEARTBEAT_SECONDS", 0.05)
monkeypatch.setattr(service, "HubClient", lambda *a, **k: Hub())
monkeypatch.setattr(service, "load_executor", lambda: _FakeExecutor())
monkeypatch.setattr(service, "execute_job", lambda *a, **k: (time.sleep(0.4), ({"ok": 1}, None))[1])
service.run(_config(agent_id="2", poll_interval_seconds=0, state_db=str(tmp_path / "a.db")), {"AGENT_ID": "2"}, stopper=stopper)
assert beats.count("BUSY") >= 3 # heartbeats during the job, not only before it
+1 -1
View File
@@ -4,5 +4,5 @@ kept identical to this) and what an AgentRelease row's `version` column holds
once published — never a second, independent version number. Fiscal semantics once published — never a second, independent version number. Fiscal semantics
are versioned separately (ENGINE_VERSION/DECISION_VERSION in classification.py, are versioned separately (ENGINE_VERSION/DECISION_VERSION in classification.py,
DECISION_ENGINE_VERSION etc.) and are NOT changed by a release bump.""" DECISION_ENGINE_VERSION etc.) and are NOT changed by a release bump."""
GTI_FISCAL_VERSION = "1.1.3" GTI_FISCAL_VERSION = "1.1.4"
API_COMPAT_MAJOR_MINOR = "1.0" # an Agent 1.0.x/1.1.x talks to any Hub 1.0.x API_COMPAT_MAJOR_MINOR = "1.0" # an Agent 1.0.x/1.1.x talks to any Hub 1.0.x
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "gti-fiscal-hub" name = "gti-fiscal-hub"
version = "1.1.3" version = "1.1.4"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"fastapi>=0.115,<1", "fastapi>=0.115,<1",
+1 -1
View File
@@ -17,7 +17,7 @@ def test_health_is_unavailable_when_database_is_down():
def test_version_endpoint_reports_release_and_fiscal_versions(): def test_version_endpoint_reports_release_and_fiscal_versions():
body = TestClient(create_app(database_probe=lambda: True)).get("/version").json() body = TestClient(create_app(database_probe=lambda: True)).get("/version").json()
assert body["version"] == "1.1.3" and body["api_compat"] == "1.0" and body["engine_version"] and body["decision_version"] assert body["version"] == "1.1.4" and body["api_compat"] == "1.0" and body["engine_version"] and body["decision_version"]
def test_production_refuses_the_dev_token(monkeypatch): def test_production_refuses_the_dev_token(monkeypatch):
+1 -1
View File
@@ -19,4 +19,4 @@ FB_PASSWORD=
FISCAL_MODE=DRY_RUN FISCAL_MODE=DRY_RUN
FIREBIRD_WRITES=0 FIREBIRD_WRITES=0
POLL_INTERVAL_SECONDS=5 POLL_INTERVAL_SECONDS=5
AGENT_VERSION=1.1.3 AGENT_VERSION=1.1.4
+1 -1
View File
@@ -164,7 +164,7 @@ switch ($Command) {
"GTI_HUB_URL=$hub", "AGENT_TOKEN=$token", "AGENT_ID=$agentId", "GTI_HUB_URL=$hub", "AGENT_TOKEN=$token", "AGENT_ID=$agentId",
"COMPANY_CODE=$code", "COMPANY_CNPJ=$cnpj", "COMPANY_CRT_SOURCE=$crtSource", "COMPANY_CODE=$code", "COMPANY_CNPJ=$cnpj", "COMPANY_CRT_SOURCE=$crtSource",
"FIREBIRD_DATABASE_PATH=$db", "FB_USER=$fbUser", "FB_PASSWORD=$fbPass", "FIREBIRD_DATABASE_PATH=$db", "FB_USER=$fbUser", "FB_PASSWORD=$fbPass",
'FISCAL_MODE=DRY_RUN', 'FIREBIRD_WRITES=0', 'POLL_INTERVAL_SECONDS=5', 'AGENT_VERSION=1.1.3' 'FISCAL_MODE=DRY_RUN', 'FIREBIRD_WRITES=0', 'POLL_INTERVAL_SECONDS=5', 'AGENT_VERSION=1.1.4'
) )
Set-Content -Path $EnvFile -Value $lines -Encoding UTF8 Set-Content -Path $EnvFile -Value $lines -Encoding UTF8
} }
@@ -19,4 +19,4 @@ FB_PASSWORD=
FISCAL_MODE=DRY_RUN FISCAL_MODE=DRY_RUN
FIREBIRD_WRITES=0 FIREBIRD_WRITES=0
POLL_INTERVAL_SECONDS=5 POLL_INTERVAL_SECONDS=5
AGENT_VERSION=1.1.3 AGENT_VERSION=1.1.4
@@ -19,4 +19,4 @@ FB_PASSWORD=
FISCAL_MODE=DRY_RUN FISCAL_MODE=DRY_RUN
FIREBIRD_WRITES=0 FIREBIRD_WRITES=0
POLL_INTERVAL_SECONDS=5 POLL_INTERVAL_SECONDS=5
AGENT_VERSION=1.1.3 AGENT_VERSION=1.1.4
@@ -19,4 +19,4 @@ FB_PASSWORD=
FISCAL_MODE=DRY_RUN FISCAL_MODE=DRY_RUN
FIREBIRD_WRITES=0 FIREBIRD_WRITES=0
POLL_INTERVAL_SECONDS=5 POLL_INTERVAL_SECONDS=5
AGENT_VERSION=1.1.3 AGENT_VERSION=1.1.4
@@ -19,4 +19,4 @@ FB_PASSWORD=
FISCAL_MODE=DRY_RUN FISCAL_MODE=DRY_RUN
FIREBIRD_WRITES=0 FIREBIRD_WRITES=0
POLL_INTERVAL_SECONDS=5 POLL_INTERVAL_SECONDS=5
AGENT_VERSION=1.1.3 AGENT_VERSION=1.1.4
+1 -1
View File
@@ -1,5 +1,5 @@
# GTI Fiscal Hub — production environment (copy to .env, never commit .env) # GTI Fiscal Hub — production environment (copy to .env, never commit .env)
GTI_FISCAL_VERSION=1.1.3 GTI_FISCAL_VERSION=1.1.4
# REQUIRED. URL-safe secret (used inside a connection URL): openssl rand -hex 24 # REQUIRED. URL-safe secret (used inside a connection URL): openssl rand -hex 24
POSTGRES_PASSWORD= POSTGRES_PASSWORD=
+1 -1
View File
@@ -36,7 +36,7 @@ services:
build: build:
context: ../.. context: ../..
dockerfile: central/Dockerfile dockerfile: central/Dockerfile
image: gti-fiscal-hub:${GTI_FISCAL_VERSION:-1.1.3} image: gti-fiscal-hub:${GTI_FISCAL_VERSION:-1.1.4}
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
postgres: postgres:
+4 -4
View File
@@ -1,14 +1,14 @@
{ {
"artifact": "RELEASE_MANIFEST", "artifact": "RELEASE_MANIFEST",
"GTI_FISCAL_VERSION": "1.1.3", "GTI_FISCAL_VERSION": "1.1.4",
"generated_at": "2026-09-24T11:46:32+00:00", "generated_at": "2026-09-24T12:33:17+00:00",
"hub": { "hub": {
"version": "1.1.3", "version": "1.1.4",
"api_compat": "1.0", "api_compat": "1.0",
"engine_version": "0.3.0", "engine_version": "0.3.0",
"decision_version": "mission-04c-ipi-ex-split" "decision_version": "mission-04c-ipi-ex-split"
}, },
"agent_version": "1.1.3", "agent_version": "1.1.4",
"fiscal_engine": { "fiscal_engine": {
"ENGINE_VERSION": "0.3.0", "ENGINE_VERSION": "0.3.0",
"DECISION_VERSION": "mission-04c-ipi-ex-split", "DECISION_VERSION": "mission-04c-ipi-ex-split",
+1 -1
View File
@@ -1 +1 @@
1.1.3 1.1.4
+2 -2
View File
@@ -14,11 +14,11 @@ sys.path.insert(0, str(ROOT))
def test_versions_are_coherent_across_hub_agent_and_packages(): def test_versions_are_coherent_across_hub_agent_and_packages():
from central.app.version import GTI_FISCAL_VERSION from central.app.version import GTI_FISCAL_VERSION
v = (ROOT / "release" / "VERSION").read_text(encoding="utf-8").strip() v = (ROOT / "release" / "VERSION").read_text(encoding="utf-8").strip()
assert v == GTI_FISCAL_VERSION == "1.1.3" assert v == GTI_FISCAL_VERSION == "1.1.4"
pep = v.replace("-rc", "rc") pep = v.replace("-rc", "rc")
for pp in ("central/pyproject.toml", "agent/pyproject.toml"): for pp in ("central/pyproject.toml", "agent/pyproject.toml"):
assert f'version = "{pep}"' in (ROOT / pp).read_text(encoding="utf-8") assert f'version = "{pep}"' in (ROOT / pp).read_text(encoding="utf-8")
assert '"AGENT_VERSION", "1.1.3"' in (ROOT / "agent" / "scripts" / "run_agent_service.py").read_text(encoding="utf-8") assert '"AGENT_VERSION", "1.1.4"' in (ROOT / "agent" / "scripts" / "run_agent_service.py").read_text(encoding="utf-8")
manifest = ROOT / "release" / "RELEASE_MANIFEST.json" manifest = ROOT / "release" / "RELEASE_MANIFEST.json"
if manifest.exists(): if manifest.exists():
m = json.loads(manifest.read_text(encoding="utf-8")) m = json.loads(manifest.read_text(encoding="utf-8"))