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:
@@ -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"]
|
||||||
|
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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=
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -1 +1 @@
|
|||||||
1.1.3
|
1.1.4
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
Reference in New Issue
Block a user