fix: persist and recover the Agent's real installation_id (heartbeat activation)

POST /v1/agents/enroll now returns installation_id; new installs persist it
to agent.env immediately. Runtime recovery precedence: existing SQLite
installation_identity -> configured INSTALLATION_ID -> authenticated
GET /v1/agents/me/identity. Recovery fails closed on missing/malformed/
mismatched identity rather than inventing a UUID (which the Hub would then
reject as a conflicting Installation on an already-enrolled Agent). An
explicit AGENT_JOBS_ENABLED gate stops the service from claiming a job right
after the first heartbeat.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
gti
2026-09-23 22:13:56 -03:00
co-authored by Claude Sonnet 5
parent a9aded1e49
commit ac7b503c2f
16 changed files with 754 additions and 15 deletions
+28 -1
View File
@@ -94,6 +94,8 @@ class Config:
self.fiscal_mode = (env.get("FISCAL_MODE") or ("AUTO_APPLY" if requested else "DRY_RUN")).strip().upper()
self.requested_writes = requested
self.firebird_writes = requested and self.fiscal_mode == "AUTO_APPLY"
jobs_enabled = (env.get("AGENT_JOBS_ENABLED", "1") or "1").strip().upper()
self.agent_jobs_enabled = jobs_enabled not in ("0", "FALSE", "NO")
self.poll_interval_seconds = float(env.get("POLL_INTERVAL_SECONDS", "5"))
self.agent_version = env.get("AGENT_VERSION", "1.1.1")
self.schema_fingerprint = env.get("SCHEMA_FINGERPRINT", REFERENCE_SCHEMA_V1)
@@ -221,6 +223,27 @@ def execute_job(job, config, executor, state, env):
return None, f"{type(error).__name__}: {error}"
def resolve_installation_id(config, env, state, client):
"""Recover and persist the Hub-bound identity; never invent one for an Agent."""
persisted = state.stored_installation_id()
configured = (env.get("INSTALLATION_ID") or "").strip() or None
if persisted is not None:
return state.installation_id(configured)
if configured is not None:
return state.installation_id(configured)
configured_agent_id = str(env.get("AGENT_ID") or config.agent_id or "").strip()
if not configured_agent_id:
raise RuntimeError("AGENT_ID is required to recover installation identity")
identity = client.identity()
if str(identity.get("agent_id") or "") != configured_agent_id:
raise RuntimeError("authenticated identity does not match configured Agent")
installation_id = identity.get("installation_id")
if not installation_id:
raise RuntimeError("authenticated identity response has no installation_id")
return state.installation_id(str(installation_id))
def run(config, env, *, max_iterations=None, stopper=None):
"""`stopper` defaults to a signal-based GracefulStop (SIGINT/SIGTERM), which
only works from the main thread. A host that manages its own lifecycle
@@ -230,7 +253,7 @@ def run(config, env, *, max_iterations=None, stopper=None):
executor = load_executor()
state = AgentState(config.state_db)
client = HubClient(config.hub_url, config.agent_token)
installation_id = state.installation_id() if hasattr(state, "installation_id") else (config.agent_id or "legacy-installation")
installation_id = resolve_installation_id(config, env, state, client)
stopper = stopper if stopper is not None else GracefulStop()
backoff = 1
@@ -266,6 +289,10 @@ def run(config, env, *, max_iterations=None, stopper=None):
backoff = min(backoff * 2, _MAX_BACKOFF_SECONDS)
continue
if not config.agent_jobs_enabled:
time.sleep(config.poll_interval_seconds)
continue
try:
job = client.next_job()
except Exception as error:
+9 -1
View File
@@ -87,7 +87,15 @@ if win32serviceutil is not None:
def main():
if win32serviceutil is None:
raise SystemExit("pywin32 is required: pip install pywin32")
win32serviceutil.HandleCommandLine(GTIFiscalAgentService)
if len(sys.argv) == 1:
# SCM launches a service executable without CLI arguments. HandleCommandLine
# requires a command (install/start/debug) and exits with usage when none
# is present, which makes SCM report error 1053 for a frozen service host.
servicemanager.Initialize()
servicemanager.PrepareToHostSingle(GTIFiscalAgentService)
servicemanager.StartServiceCtrlDispatcher()
else:
win32serviceutil.HandleCommandLine(GTIFiscalAgentService)
if __name__ == "__main__":
+1
View File
@@ -13,6 +13,7 @@ class HubClient:
def get_json(self, path):
response = httpx.get(f"{self.url}{path}", headers=self._headers(), timeout=30)
response.raise_for_status(); return response.json()
def identity(self): return self.get_json("/v1/agents/me/identity")
def health(self):
response = httpx.get(f"{self.url}/health", timeout=10)
return response.status_code == 200
+24 -5
View File
@@ -9,12 +9,31 @@ class AgentState:
self.connection.executescript("CREATE TABLE IF NOT EXISTS product_state(product_id TEXT PRIMARY KEY, source_fingerprint TEXT NOT NULL, dataset_version TEXT NOT NULL, engine_version TEXT NOT NULL, status TEXT NOT NULL, last_processed_at TEXT, last_result_hash TEXT, reason_code TEXT); CREATE TABLE IF NOT EXISTS outbox(product_id TEXT PRIMARY KEY, source_fingerprint TEXT NOT NULL, status TEXT NOT NULL, retries INTEGER NOT NULL DEFAULT 0); CREATE TABLE IF NOT EXISTS ipi_write_log(product_id TEXT NOT NULL, source_fingerprint TEXT NOT NULL, decision_status TEXT, ipi_status TEXT, write_status TEXT NOT NULL, before_value TEXT, proposed_value TEXT, result TEXT, reason_code TEXT, timestamp TEXT NOT NULL);")
self.connection.execute("CREATE TABLE IF NOT EXISTS installation_identity (id INTEGER PRIMARY KEY CHECK(id=1), installation_id TEXT NOT NULL)")
self.connection.commit()
def installation_id(self):
def stored_installation_id(self):
row = self.connection.execute("SELECT installation_id FROM installation_identity WHERE id=1").fetchone()
if row:
return row["installation_id"]
value = str(uuid.uuid4())
self.connection.execute("INSERT INTO installation_identity(id, installation_id) VALUES(1, ?)", (value,)); self.connection.commit()
if row is None:
return None
try:
return str(uuid.UUID(row["installation_id"]))
except (TypeError, ValueError, AttributeError) as error:
raise ValueError("stored installation_id is not a valid UUID") from error
def installation_id(self, candidate=None):
existing = self.stored_installation_id()
if existing:
if candidate is not None and str(uuid.UUID(str(candidate))) != existing:
raise ValueError("configured installation_id conflicts with persisted identity")
return existing
if candidate is None or not str(candidate).strip():
raise ValueError("installation_id is required; refusing to generate a new identity")
try:
value = str(uuid.UUID(str(candidate)))
except (TypeError, ValueError, AttributeError) as error:
raise ValueError("installation_id must be a valid UUID") from error
self.connection.execute("INSERT OR IGNORE INTO installation_identity(id, installation_id) VALUES(1, ?)", (value,))
self.connection.commit()
row = self.connection.execute("SELECT installation_id FROM installation_identity WHERE id=1").fetchone()
if row is None or str(uuid.UUID(row["installation_id"])) != value:
raise ValueError("persisted installation_id conflicts with candidate")
return value
def enqueue(self, product_id, source_fingerprint, dataset_version, engine_version):
row = self.connection.execute("SELECT * FROM product_state WHERE product_id=?", (product_id,)).fetchone()
+34
View File
@@ -0,0 +1,34 @@
import httpx
from gti_fiscal_agent.client import HubClient
def test_identity_requests_authenticated_identity_only(monkeypatch):
calls = {}
payload = {
"agent_id": 2,
"installation_id": "00000000-0000-4000-8000-000000000222",
"hostname": "DESKTOP-B0HL02I",
"version": "1.1.1",
"status": "OFFLINE",
}
class Response:
status_code = 200
def raise_for_status(self):
return None
def json(self):
return payload
def fake_get(url, *, headers, timeout):
calls.update(url=url, headers=headers, timeout=timeout)
return Response()
monkeypatch.setattr(httpx, "get", fake_get)
client = HubClient("https://hub.example", "test-agent-token")
assert client.identity() == payload
assert calls["url"] == "https://hub.example/v1/agents/me/identity"
assert calls["headers"] == {"Authorization": "Bearer test-agent-token"}
+122 -3
View File
@@ -22,6 +22,19 @@ service = load_module()
class _FakeState:
def __init__(self):
self.logged = []
self.identity = None
def stored_installation_id(self):
return self.identity
def installation_id(self, candidate=None):
if self.identity and candidate and self.identity != candidate:
raise ValueError("configured installation_id conflicts with persisted identity")
if self.identity is None:
if not candidate:
raise ValueError("installation_id is required")
self.identity = candidate
return self.identity
def log_ipi_write(self, *args):
self.logged.append(args)
@@ -61,7 +74,7 @@ def _config(**overrides):
base = dict(
hub_url="http://localhost:8000", agent_id="a1", agent_token="tok", company_id="c1", company_uf="BA",
database_path=AUTHORIZED, firebird_writes=False, poll_interval_seconds=0.01,
agent_version="0.1.0", schema_fingerprint="fp", state_db=":memory:",
agent_version="0.1.0", schema_fingerprint="fp", state_db=":memory:", agent_jobs_enabled=True,
authorized_write_database_path=None, expected_schema_fingerprint="fp",
)
base.update(overrides)
@@ -74,6 +87,13 @@ def test_config_defaults_from_env():
assert config.firebird_writes is False
assert config.hub_url == "http://localhost:8000"
assert config.poll_interval_seconds == 5.0
assert config.agent_jobs_enabled is True
def test_config_can_disable_agent_job_polling():
config = service.Config({"FIREBIRD_DATABASE_PATH": AUTHORIZED, "AGENT_JOBS_ENABLED": "0"})
assert config.agent_jobs_enabled is False
def test_config_reads_gti_hub_url_and_writes_flag():
@@ -83,6 +103,54 @@ def test_config_reads_gti_hub_url_and_writes_flag():
assert config.firebird_writes is True
def test_identity_recovery_persists_existing_hub_installation_id(tmp_path):
config = _config(agent_id="2")
state = service.AgentState(tmp_path / "agent.db")
installation_id = "00000000-0000-4000-8000-000000000222"
class FakeIdentityClient:
calls = 0
def identity(self):
self.calls += 1
return {"agent_id": 2, "installation_id": installation_id}
client = FakeIdentityClient()
recovered = service.resolve_installation_id(config, {}, state, client)
assert recovered == installation_id
assert state.installation_id() == installation_id
assert client.calls == 1
restarted = service.resolve_installation_id(config, {}, state, client)
assert restarted == installation_id
assert client.calls == 1
def test_identity_recovery_fails_closed_for_another_agent_or_missing_id(tmp_path):
config = _config(agent_id="2")
class FakeIdentityClient:
def __init__(self, payload):
self.payload = payload
def identity(self):
return self.payload
state = service.AgentState(tmp_path / "agent.db")
with pytest.raises(RuntimeError, match="identity does not match configured Agent"):
service.resolve_installation_id(
config,
{},
state,
FakeIdentityClient({"agent_id": 3, "installation_id": "00000000-0000-4000-8000-000000000333"}),
)
assert state.connection.execute("SELECT COUNT(*) FROM installation_identity").fetchone()[0] == 0
with pytest.raises(RuntimeError, match="identity response has no installation_id"):
service.resolve_installation_id(config, {}, state, FakeIdentityClient({"agent_id": 2}))
assert state.connection.execute("SELECT COUNT(*) FROM installation_identity").fetchone()[0] == 0
def test_path_guard_fails_closed_before_any_write(monkeypatch):
config = _config(firebird_writes=True, database_path=r"D:\PRODUCAO\ERP.FDB")
executor = _FakeExecutor(path_guard_ok=False)
@@ -226,6 +294,57 @@ class _ExternalStopFlag:
self.stop = False
def test_run_sends_heartbeat_but_never_polls_when_jobs_disabled(monkeypatch, tmp_path):
stopper = _ExternalStopFlag()
fake_hub = _FakeHubForLoop()
installation_id = "00000000-0000-4000-8000-000000000222"
calls = {"identity": 0, "next_job": 0, "heartbeat": 0, "heartbeat_installation_id": None}
def identity():
calls["identity"] += 1
return {"agent_id": 2, "installation_id": installation_id}
def heartbeat_metadata(payload):
calls["heartbeat"] += 1
calls["heartbeat_installation_id"] = payload["installation_id"]
stopper.stop = True
return {"agent_id": 2}
def forbidden_next_job():
calls["next_job"] += 1
raise AssertionError("jobs are disabled; next_job must not be called")
fake_hub.identity = identity
fake_hub.heartbeat_metadata = heartbeat_metadata
fake_hub.next_job = forbidden_next_job
monkeypatch.setattr(service, "HubClient", lambda *a, **k: fake_hub)
monkeypatch.setattr(service, "load_executor", lambda: _FakeExecutor())
monkeypatch.setattr(service.time, "sleep", lambda _seconds: None)
config = _config(
agent_id="2",
agent_jobs_enabled=False,
poll_interval_seconds=0,
state_db=str(tmp_path / "agent.db"),
)
env = {
"AGENT_ID": "2",
"FIREBIRD_DATABASE_PATH": AUTHORIZED,
"FIREBIRD_WRITES": "0",
"FISCAL_MODE": "DRY_RUN",
}
service.run(config, env, stopper=stopper)
assert calls == {
"identity": 1,
"next_job": 0,
"heartbeat": 1,
"heartbeat_installation_id": installation_id,
}
assert service.AgentState(config.state_db).stored_installation_id() == installation_id
def test_run_honors_an_externally_supplied_stopper(monkeypatch):
# This is exactly the pattern the Windows Service wrapper uses: it never
# constructs GracefulStop() (which would crash off the main thread), it
@@ -248,7 +367,7 @@ def test_run_honors_an_externally_supplied_stopper(monkeypatch):
stopper.stop = True
monkeypatch.setattr(service.time, "sleep", counting_sleep)
service.run(config, {}, stopper=stopper) # no max_iterations — only the external flag can stop it
service.run(config, {"AGENT_ID": "2", "INSTALLATION_ID": "00000000-0000-4000-8000-000000000222"}, stopper=stopper) # no max_iterations — only the external flag can stop it
assert stopper.stop is True
assert calls["n"] >= 3
@@ -264,7 +383,7 @@ def test_run_survives_hub_down_then_processes_one_job_and_stops(monkeypatch):
monkeypatch.setattr(service.time, "sleep", lambda seconds: None)
# first heartbeat call raises -> backoff branch; second call onward succeeds.
service.run(config, {}, max_iterations=4)
service.run(config, {"AGENT_ID": "2", "INSTALLATION_ID": "00000000-0000-4000-8000-000000000222"}, max_iterations=4)
assert fake_hub.heartbeat_calls >= 2
assert len(fake_hub.completed) == 1
+36
View File
@@ -1,3 +1,5 @@
import pytest
from gti_fiscal_agent.state import AgentState
@@ -18,3 +20,37 @@ def test_ipi_write_log_records_per_product_audit(tmp_path):
assert log[0]["write_status"] == "SUCCEEDED"
assert log[0]["before_value"] is None
assert log[0]["proposed_value"] == "3.25"
def test_installation_identity_requires_an_existing_or_explicit_id(tmp_path):
state = AgentState(tmp_path / "agent.db")
assert state.stored_installation_id() is None
with pytest.raises(ValueError, match="installation_id is required"):
state.installation_id()
assert state.connection.execute("SELECT COUNT(*) FROM installation_identity").fetchone()[0] == 0
def test_installation_identity_persists_and_reuses_explicit_id(tmp_path):
db_path = tmp_path / "agent.db"
state = AgentState(db_path)
existing_id = "00000000-0000-4000-8000-000000000222"
assert state.installation_id(existing_id) == existing_id
assert state.installation_id() == existing_id
assert state.stored_installation_id() == existing_id
restarted_state = AgentState(db_path)
assert restarted_state.installation_id() == existing_id
def test_installation_identity_rejects_conflicting_or_malformed_ids(tmp_path):
state = AgentState(tmp_path / "agent.db")
existing_id = "00000000-0000-4000-8000-000000000222"
state.installation_id(existing_id)
with pytest.raises(ValueError, match="conflicts"):
state.installation_id("00000000-0000-4000-8000-000000000223")
with pytest.raises(ValueError, match="UUID"):
state.installation_id("not-a-uuid")
+58
View File
@@ -0,0 +1,58 @@
import importlib.util
import sys
from pathlib import Path
from types import SimpleNamespace
SERVICE_WRAPPER = Path(__file__).parents[1] / "service" / "gti_agent_windows_service.py"
def load_service_wrapper():
spec = importlib.util.spec_from_file_location("gti_agent_windows_service_test", SERVICE_WRAPPER)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def test_main_enters_scm_dispatcher_when_started_without_command(monkeypatch):
service = load_service_wrapper()
calls = []
class FakeServiceManager:
def Initialize(self):
calls.append("initialize")
def PrepareToHostSingle(self, service_class):
calls.append(("prepare", service_class))
def StartServiceCtrlDispatcher(self):
calls.append("dispatcher")
def forbidden_handle_command_line(service_class):
raise AssertionError("SCM start must not be parsed as a CLI command")
monkeypatch.setattr(service, "servicemanager", FakeServiceManager())
monkeypatch.setattr(service, "win32serviceutil", SimpleNamespace(HandleCommandLine=forbidden_handle_command_line))
monkeypatch.setattr(service.sys, "argv", [str(SERVICE_WRAPPER)])
service.main()
assert calls == ["initialize", ("prepare", service.GTIFiscalAgentService), "dispatcher"]
def test_main_preserves_pywin32_cli_commands(monkeypatch):
service = load_service_wrapper()
called = []
class ForbiddenServiceManager:
def __getattr__(self, name):
raise AssertionError("CLI commands must not enter the SCM dispatcher")
monkeypatch.setattr(service, "servicemanager", ForbiddenServiceManager())
monkeypatch.setattr(service, "win32serviceutil", SimpleNamespace(HandleCommandLine=lambda cls: called.append(cls)))
monkeypatch.setattr(service.sys, "argv", [str(SERVICE_WRAPPER), "start"])
service.main()
assert called == [service.GTIFiscalAgentService]