# Agent Installation Identity Recovery and First Heartbeat Implementation Plan > **For agentic workers:** Use `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox syntax for tracking. **Goal:** Recover and persist Agent 2's existing Hub `installation_id`, send exactly the first safe heartbeat, and stop without claiming jobs or writing Firebird. **Architecture:** The Hub exposes the already-linked installation identity over an authenticated, read-only endpoint. New enrollments return that same ID so Setup can persist it; legacy installations recover it at runtime and commit it into the existing SQLite identity table before heartbeat. `AGENT_JOBS_ENABLED=0` skips the job API entirely during activation. **Tech Stack:** FastAPI, SQLAlchemy, pytest, Python `fdb`, SQLite local state, PyInstaller onedir service host, Windows SCM / PowerShell. ## Global Constraints - Keep Agent ID `2`; do not create an Agent, reenroll, or create a second Installation. - Never generate a fallback `installation_id` for an already-registered Agent. - Do not call `/v1/jobs/next`, classification endpoints, or any job execution path during activation. - Preserve `FIREBIRD_WRITES=0`, `FISCAL_MODE=DRY_RUN`, and empty `AUTHORIZED_WRITE_DATABASE_PATH`. - All Firebird connections used by preflight remain read-only; issue no Firebird DML/DDL. - Do not rerun Setup.exe or replace `agent.env` / `credentials.bin` during service-host update. - Keep published `GTIFiscalAgent-Setup-1.1.1.exe` immutable; update only the installed service runtime. - Never print or log `FB_PASSWORD`, the Agent bearer token, or credential-file contents. - Do not commit unless the operator explicitly requests a commit. --- ### Task 1: Hub installation identity response **Files:** - Modify: `central/app/api.py` - Test: `central/tests/test_jobs.py` **Interfaces:** - Add authenticated `GET /v1/agents/me/identity`. - Return exactly `{agent_id, installation_id, hostname, version, status}`; `installation_id` and `hostname` are `None` if the Agent has no Installation. - Do not change the existing `GET /v1/agents/me` response. - [ ] **Step 1: Write the failing identity endpoint tests** Add tests that create an Agent with one Installation (`installation_id` fixed UUID, hostname `DESKTOP-B0HL02I`, version `1.1.1`) and assert: ```python response = client.get("/v1/agents/me/identity", headers=headers) assert response.status_code == 200 assert response.json() == { "agent_id": agent.id, "installation_id": installation.installation_id, "hostname": "DESKTOP-B0HL02I", "version": "1.1.1", "status": "OFFLINE", } ``` Also assert unauthenticated requests return 401 and an Agent without an Installation returns HTTP 200 with nullable identity fields, without creating an Installation. - [ ] **Step 2: Run the focused tests and verify failure** Run: `python -m pytest central/tests/test_jobs.py -q` Expected: the identity route returns 404 before implementation. - [ ] **Step 3: Implement the authenticated endpoint** Use the existing `principal` dependency, query `Installation` by `agent.id`, and derive status with the same online-threshold rule as `/v1/agents/me`. The route must not commit or mutate database state. - [ ] **Step 4: Run the focused tests** Run: `python -m pytest central/tests/test_jobs.py -q` Expected: all tests pass; existing `/v1/agents/me` tests remain unchanged and green. ### Task 2: Return and persist identity during new enrollment **Files:** - Modify: `central/app/agent_distribution.py` - Test: `central/tests/test_install_code_enrollment.py` - Modify: `installer/install_common.py` - Test: `tests/test_installer_artifacts.py` **Interfaces:** - Successful `/v1/agents/enroll` response adds `installation_id=enr.enrollment_uuid`. - `write_agent_env(install_dir_path, enroll_result)` writes `INSTALLATION_ID` as a non-secret field. It never writes `agent_token` or `AGENT_TOKEN`. - [ ] **Step 1: Add failing enrollment and Setup-env assertions** In the existing install-code happy-path test, assert `body["installation_id"] == uuid`. In the installer artifact test, pass a fixed ID such as `00000000-0000-4000-8000-000000000222` and assert the env includes it while the token is absent. - [ ] **Step 2: Run focused tests and verify failure** Run: `python -m pytest central/tests/test_install_code_enrollment.py::test_enroll_with_install_code_alone_creates_agent tests/test_installer_artifacts.py -q` Expected: failures show the response/env currently omit `installation_id`. - [ ] **Step 3: Add the returned ID and env serialization** Add `"installation_id": enr.enrollment_uuid` to the successful enrollment JSON. Add `INSTALLATION_ID` to `write_agent_env`; do not change credential storage or Firebird guard values. - [ ] **Step 4: Run focused tests** Run the same command as Step 2. Expected: enrollment and installer artifact tests pass; `agent.env` remains token-free with `FIREBIRD_WRITES=0` and `FISCAL_MODE=DRY_RUN`. ### Task 3: Persist or recover identity before heartbeat **Files:** - Modify: `agent/src/gti_fiscal_agent/state.py` - Modify: `agent/src/gti_fiscal_agent/client.py` - Modify: `agent/scripts/run_agent_service.py` - Test: `agent/tests/test_state.py` - Test: `agent/tests/test_run_agent_service.py` **Interfaces:** - `AgentState.installation_id(candidate: str | None = None) -> str` returns the persisted UUID; if no UUID is stored, it persists a valid candidate; if neither exists, it raises instead of generating a random UUID. A different candidate from an already-persisted UUID raises. - `HubClient.identity() -> dict` calls authenticated `GET /v1/agents/me/identity`. - `resolve_installation_id(config, env, state, client) -> str` selects persisted state, then `INSTALLATION_ID`, then authenticated identity; verifies returned `agent_id` equals `config.agent_id`; persists before returning. - [ ] **Step 1: Write failing state identity tests** Cover: empty store plus valid candidate persists and returns it; second call returns the same ID; conflicting candidate raises; missing candidate does not generate; malformed UUID raises. - [ ] **Step 2: Run state tests and verify failure** Run: `python -m pytest agent/tests/test_state.py -q` Expected: the missing identity case currently generates a UUID and the persistence API is absent. - [ ] **Step 3: Implement deterministic SQLite identity persistence** Validate candidate values with `uuid.UUID`. Within a SQLite transaction, read the existing row, compare any candidate, insert only when absent, and return the single stored canonical UUID. Remove random UUID creation from the registered-agent path. - [ ] **Step 4: Write failing runtime bootstrap tests** Use a fake Hub identity response and fake state. Assert missing local identity calls `identity()` once, verifies matching Agent ID, persists the returned UUID, and uses it in heartbeat. Assert a mismatched Agent ID, null ID, or identity request error fails before heartbeat. Assert an already-persisted ID avoids the identity request. - [ ] **Step 5: Run runtime tests and verify failure** Run: `python -m pytest agent/tests/test_run_agent_service.py -q` Expected: current loop calls the random-UUID-producing state method and has no authenticated identity bootstrap. - [ ] **Step 6: Implement identity bootstrap before the heartbeat loop** Add `HubClient.identity()`, implement `resolve_installation_id`, and invoke it after constructing the Hub client/state but before entering the loop. An identity failure must exit before heartbeat and before job polling. - [ ] **Step 7: Run focused state/runtime tests** Run: `python -m pytest agent/tests/test_state.py agent/tests/test_run_agent_service.py -q` Expected: persisted/recovered identity tests pass and mismatches fail closed. ### Task 4: Disable job polling without disabling heartbeat **Files:** - Modify: `agent/scripts/run_agent_service.py` - Test: `agent/tests/test_run_agent_service.py` - Modify on this host only: `C:\Program Files\GTI\FiscalAgent\agent.env` **Interfaces:** - `Config.agent_jobs_enabled` parses `AGENT_JOBS_ENABLED`, default `True`. - When false, the loop sleeps for `poll_interval_seconds` and continues after heartbeat without calling `HubClient.next_job()`. - [ ] **Step 1: Add the failing no-poll test** Use a fake Hub whose `next_job()` raises if called. Make its first heartbeat set the external stop flag. Run one loop cycle with `AGENT_JOBS_ENABLED=0` and assert one heartbeat, zero `next_job` calls, and zero job/classification calls. - [ ] **Step 2: Run the focused test and verify failure** Run: `python -m pytest agent/tests/test_run_agent_service.py -q` Expected: the current loop calls `next_job()` immediately after heartbeat. - [ ] **Step 3: Implement the gate with a backward-compatible default** Parse `AGENT_JOBS_ENABLED` as enabled unless its stripped, uppercased value is `0`, `FALSE`, or `NO`. After successful heartbeat, if disabled, sleep once and continue before constructing or calling the job endpoint. - [ ] **Step 4: Run focused runtime tests** Run: `python -m pytest agent/tests/test_run_agent_service.py -q` Expected: disabled polling makes no `next_job` request; default configuration retains the existing job behavior. ### Task 5: Full regressions and guard review **Files:** - Verify: `central/tests/`, `agent/tests/`, `tests/` - [ ] **Step 1: Run Hub and Agent regressions** Run: `python -m pytest central/tests -q` and `python -m pytest agent/tests -q`. Expected: both suites pass. - [ ] **Step 2: Run repository offline regressions** Run: `python -m pytest tests -q`. Expected: all tests pass with only the repository's pre-existing skip. - [ ] **Step 3: Verify write and secret invariants** Assert tests and source still guarantee `FIREBIRD_WRITES=0`, `FISCAL_MODE=DRY_RUN`, empty `AUTHORIZED_WRITE_DATABASE_PATH`, no token in `agent.env`, and identity endpoint contains only the approved fields. Do not connect to Firebird for this check. ### Task 6: Deploy the API and update only the installed service runtime **Files/artifacts:** - Deploy Hub source: `central/app/api.py`, `central/app/agent_distribution.py` - Build artifact: `dist/installer/GTIFiscalAgentService/` only - Existing install: `C:\Program Files\GTI\FiscalAgent\GTIFiscalAgentService\` - [ ] **Step 1: Deploy the Hub code without a migration** Use the approved production deployment path and preserve the existing Hub `.env`, PostgreSQL volume, release volume, SSO, and DNS. Build/restart only `fiscal-hub`; verify `/health`, `/version`, and authenticated `/v1/agents/me/identity` return Agent 2's already-existing identity. Do not query the database directly to retrieve the ID. - [ ] **Step 2: Build only the service-host onedir** Record SHA-256 of `dist/installer/GTIFiscalAgent-Setup-1.1.1.exe` before building. Verify no unversioned `GTIFiscalAgent-Setup.exe` exists, then run `scripts/build_windows_installer.ps1 -SkipSetup -SkipUninstall`. Confirm the versioned Setup hash is unchanged and no Setup.exe is run. - [ ] **Step 3: Back up and replace only the service runtime** With `GTIFiscalAgent` stopped, copy the current installed `GTIFiscalAgentService` directory to a timestamped local backup. Replace only that directory with the newly built service onedir. Leave `agent.env`, `credentials.bin`, `Uninstall.exe`, registry/ARP entries, and state files untouched. - [ ] **Step 4: Prepare the existing installation for heartbeat-only operation** Elevated-edit `agent.env` to set `AGENT_JOBS_ENABLED=0`, preserving `FIREBIRD_WRITES=0`, `FISCAL_MODE=DRY_RUN`, the existing Agent ID, Firebird configuration, and SYSTEM/Administrators ACL. Do not put the bearer token in this file. - [ ] **Step 5: Verify recovery before SCM start** Run the installed runtime's read-only identity bootstrap/preflight with the existing DPAPI credential. Confirm recovered `agent_id=2`, `installation_id` equals the endpoint value, the SQLite state contains that same ID, and jobs are disabled. If any check fails, leave the service stopped. ### Task 7: First heartbeat only, then stop **Files:** - Existing Windows service: `GTIFiscalAgent` - Existing Hub identity endpoint: `GET /v1/agents/me/identity` - [ ] **Step 1: Capture a baseline** Before service start, record the existing local `ipi_write_log` row count and confirm service state is STOPPED. Do not call `/v1/jobs/next` or submit classification requests. - [ ] **Step 2: Start the existing service** Run `sc.exe start GTIFiscalAgent`; wait for SCM state RUNNING and verify the identity endpoint shows `agent_id=2`, the same stored `installation_id`, correct hostname, version `1.1.1`, and status ONLINE. - [ ] **Step 3: Stop immediately after first heartbeat** Run `sc.exe stop GTIFiscalAgent`; verify STOPPED. Keep `AGENT_JOBS_ENABLED=0` in `agent.env`. - [ ] **Step 4: Verify zero jobs and writes** Compare local `ipi_write_log` count with baseline; assert unchanged and equal to zero. Confirm no classification request or job was sent, `FIREBIRD_WRITES=0`, and `WRITE_ATTEMPTS=0` / `WRITE_SUCCESSES=0`. Verify authenticated identity still maps to Agent 2 and the original installation ID; do not create or enroll anything. - [ ] **Step 5: Verify user-visible status if panel access is available** Confirm Agent 2 displays ONLINE with the host name and version 1.1.1. If the panel requires unavailable human authentication, report the authenticated identity endpoint evidence and leave the panel UI item as a blocker; do not bypass authentication. ## Rollback and stop gates - If Hub identity response is absent, mismatched, or does not name Agent 2, stop before writing the local identity or starting SCM. - If Hub deployment or service-host replacement fails, restore the backed-up service directory and leave the service stopped. - If `AGENT_JOBS_ENABLED=0` is not verified, do not start the service. - If preflight, identity, hostname, version, or write guards fail, stop and report; do not retry enrollment, generate another UUID, run a job, or write Firebird.