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:
@@ -0,0 +1,249 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user