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>
14 KiB
Agent Installation Identity Recovery and First Heartbeat Implementation Plan
For agentic workers: Use
superpowers:executing-plansto 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_idfor 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 emptyAUTHORIZED_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.binduring service-host update. - Keep published
GTIFiscalAgent-Setup-1.1.1.exeimmutable; 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_idandhostnameareNoneif the Agent has no Installation. -
Do not change the existing
GET /v1/agents/meresponse. -
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:
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/enrollresponse addsinstallation_id=enr.enrollment_uuid. -
write_agent_env(install_dir_path, enroll_result)writesINSTALLATION_IDas a non-secret field. It never writesagent_tokenorAGENT_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) -> strreturns 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() -> dictcalls authenticatedGET /v1/agents/me/identity. -
resolve_installation_id(config, env, state, client) -> strselects persisted state, thenINSTALLATION_ID, then authenticated identity; verifies returnedagent_idequalsconfig.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_enabledparsesAGENT_JOBS_ENABLED, defaultTrue. -
When false, the loop sleeps for
poll_interval_secondsand continues after heartbeat without callingHubClient.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=0is 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.