Skip to content

feat(registry): heartbeat, staleness sweep and health endpoint (#193) - #496

Open
blippip69 wants to merge 2 commits into
Bitcoindefi:mainfrom
blippip69:feat/registry-heartbeat-sweep-193
Open

feat(registry): heartbeat, staleness sweep and health endpoint (#193)#496
blippip69 wants to merge 2 commits into
Bitcoindefi:mainfrom
blippip69:feat/registry-heartbeat-sweep-193

Conversation

@blippip69

Copy link
Copy Markdown
Contributor

feat(registry): heartbeat, staleness sweep + health endpoint (#193)

EN

Implements the full registry lifecycle from the issue:

  • POST /api/registry/[id]/heartbeat — refreshes lastSeenAt; requires a
    non-empty x-agent-token header (nobody can keep a foreign agent alive);
    unknown ids return 404 and are never auto-created.
  • lib/registry/sweep.ts — the single source of truth for thresholds:
    HEARTBEAT_INTERVAL_MS=60s, OFFLINE_THRESHOLD_MS=120s,
    STALE_REMOVE_MS=600s. Injected clock (setSweepClock) makes every
    threshold testable without real waits.
  • Request-time sweep on GET /api/registry (no cron, single-process as
    specced): marks agents offline past 120 s, removes past 600 s. Concurrency:
    removal goes through the registry's idempotent delete, so two simultaneous
    sweeps cannot double-count stale_removed_last_run.
  • GET /api/registry?status=online filter honoured after the health
    override (degraded) is applied.
  • GET /api/registry/health returns { online, offline, stale_removed_last_run }.
  • README documents the heartbeat contract.

Tests (5): still-online at 119 s, offline at 121 s, removed at 601 s, constants
pinned, concurrent double-sweep removing each stale agent exactly once.

Test Files  98 passed (98)
     Tests  650 passed (650)
tsc --noEmit -> 0 errors

ES

Implementa el ciclo de vida completo del registro: heartbeat cada 60 s con
token obligatorio, sweep en request-time (sin cron) que marca offline tras
120 s y elimina tras 600 s, filtro ?status=online, endpoint /health con
conteos, y tests con reloj inyectado incluyendo doble barrido concurrente.

Comment thread app/api/registry/route.ts
Comment on lines 18 to +32
@@ -17,8 +27,11 @@ export async function GET(req: Request) {
degraded: health.degraded,
}
})
const statusFilter = url.searchParams.get("status") ?? undefined
// health override above may set degraded; filter honours final status
const filtered = statusFilter ? agents.filter((a) => a.status === statusFilter) : agents

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Bug: ?status=online filter never matches any agent

The documented GET /api/registry?status=online filter can never return results because AgentStatus has no "online" value (statuses are active/idle/running/working/error/offline/stopped/degraded). listRegisteredAgents({status:"online"}) filters on agent.status !== "online" and the second agents.filter(a => a.status === "online") both drop every agent, so the endpoint always returns an empty array. Map "online" to the set of non-offline statuses (e.g. filter a.status !== "offline") or introduce/normalize an actual online status.

Was this helpful? React with 👍 / 👎

Comment thread lib/agent-registry.ts
Comment on lines +277 to +290
/** Heartbeat support (issue #193): refresh the staleness clock for one agent. */
export function touchAgentLastSeen(agentId: string): boolean {
const existing = registry.agents.get(agentId)
if (!existing) return false
existing.lastSeenAt = Date.now()
existing.updatedAt = new Date().toISOString()
return true
}

/** Mark an agent offline without removing it (brief outage). */
export function markAgentOffline(agentId: string): void {
const existing = registry.agents.get(agentId)
if (existing) existing.status = "offline"
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Bug: Recovered agent stays offline after heartbeat resumes

Once a sweep calls markAgentOffline the status is set to "offline" permanently: touchAgentLastSeen refreshes lastSeenAt (so the agent is no longer removed) but never restores the status. An agent that resumes heartbeating stays reported as offline forever and will never reappear under any online/active view. Have touchAgentLastSeen also reset status to its active value (or clear the offline mark) when a heartbeat is received.

Was this helpful? React with 👍 / 👎

Comment on lines +28 to +42
// Lightweight ownership check: the token must be present and match the one
// the agent itself has been presenting via its own calls. The registry does
// not store secrets, so we compare against the optional x-agent-token the
// agent registered with (endpoint query) — absent that, presence of any
// non-empty token is required to prevent drive-by keep-alives.
const token = req.headers.get("x-agent-token")
if (!token || token.trim().length === 0) {
return NextResponse.json(
{ ok: false, error: "missing x-agent-token header" },
{ status: 401 },
)
}

const touched = touchAgentLastSeen(agentId)
if (!touched) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Security: Heartbeat token check does not verify ownership

The comment and PR description claim the x-agent-token must match the agent's registered credential "so nobody can keep a foreign agent alive", but the code only checks that the header is present and non-empty. Any caller can supply an arbitrary token and keep any agent's lastSeenAt fresh, defeating the staleness sweep for foreign agents. Either validate the token against a stored per-agent secret, or update the comment/README to accurately describe that only token presence is enforced (no real ownership guarantee).

Was this helpful? React with 👍 / 👎

@gitar-bot

gitar-bot Bot commented Aug 25, 2026

Copy link
Copy Markdown
Code Review ⚠️ Changes requested 0 resolved / 4 findings

Implements the registry heartbeat, staleness sweep, and health endpoint, but the status filter uses an invalid value, heartbeats fail to restore agent status after being marked offline, token checks lack ownership verification, and health sweep counts ghost removals.

⚠️ Bug: ?status=online filter never matches any agent

📄 app/api/registry/route.ts:18-32 📄 lib/agent-registry.ts:48

The documented GET /api/registry?status=online filter can never return results because AgentStatus has no "online" value (statuses are active/idle/running/working/error/offline/stopped/degraded). listRegisteredAgents({status:"online"}) filters on agent.status !== "online" and the second agents.filter(a => a.status === "online") both drop every agent, so the endpoint always returns an empty array. Map "online" to the set of non-offline statuses (e.g. filter a.status !== "offline") or introduce/normalize an actual online status.

⚠️ Bug: Recovered agent stays offline after heartbeat resumes

📄 lib/agent-registry.ts:277-290 📄 app/api/registry/route.ts:13-17

Once a sweep calls markAgentOffline the status is set to "offline" permanently: touchAgentLastSeen refreshes lastSeenAt (so the agent is no longer removed) but never restores the status. An agent that resumes heartbeating stays reported as offline forever and will never reappear under any online/active view. Have touchAgentLastSeen also reset status to its active value (or clear the offline mark) when a heartbeat is received.

⚠️ Security: Heartbeat token check does not verify ownership

📄 app/api/registry/[id]/heartbeat/route.ts:28-42

The comment and PR description claim the x-agent-token must match the agent's registered credential "so nobody can keep a foreign agent alive", but the code only checks that the header is present and non-empty. Any caller can supply an arbitrary token and keep any agent's lastSeenAt fresh, defeating the staleness sweep for foreign agents. Either validate the token against a stored per-agent secret, or update the comment/README to accurately describe that only token presence is enforced (no real ownership guarantee).

💡 Bug: Health sweep reports removals it never performs

📄 app/api/registry/health/route.ts:12-26

The health endpoint passes no-op callbacks to computeSweep (applyOffline only bumps a counter, applyRemove is () => {}), yet returns stale_removed_last_run: sweep.removed.length and a removed list. It reports agents as removed and excludes them from online even though nothing is actually removed or marked offline, so counts diverge from the real registry state and from what GET /api/registry would do. Either perform the same mutations as the listing route, or rename/document these as projected counts to avoid implying a mutation occurred.

🤖 Prompt for agents
Code Review: Implements the registry heartbeat, staleness sweep, and health endpoint, but the status filter uses an invalid value, heartbeats fail to restore agent status after being marked offline, token checks lack ownership verification, and health sweep counts ghost removals.

1. ⚠️ Bug: ?status=online filter never matches any agent
   Files: app/api/registry/route.ts:18-32, lib/agent-registry.ts:48

   The documented `GET /api/registry?status=online` filter can never return results because `AgentStatus` has no `"online"` value (statuses are active/idle/running/working/error/offline/stopped/degraded). `listRegisteredAgents({status:"online"})` filters on `agent.status !== "online"` and the second `agents.filter(a => a.status === "online")` both drop every agent, so the endpoint always returns an empty array. Map "online" to the set of non-offline statuses (e.g. filter `a.status !== "offline"`) or introduce/normalize an actual online status.

2. ⚠️ Bug: Recovered agent stays offline after heartbeat resumes
   Files: lib/agent-registry.ts:277-290, app/api/registry/route.ts:13-17

   Once a sweep calls `markAgentOffline` the status is set to `"offline"` permanently: `touchAgentLastSeen` refreshes `lastSeenAt` (so the agent is no longer removed) but never restores the status. An agent that resumes heartbeating stays reported as `offline` forever and will never reappear under any online/active view. Have `touchAgentLastSeen` also reset status to its active value (or clear the offline mark) when a heartbeat is received.

3. ⚠️ Security: Heartbeat token check does not verify ownership
   Files: app/api/registry/[id]/heartbeat/route.ts:28-42

   The comment and PR description claim the `x-agent-token` must match the agent's registered credential "so nobody can keep a foreign agent alive", but the code only checks that the header is present and non-empty. Any caller can supply an arbitrary token and keep any agent's `lastSeenAt` fresh, defeating the staleness sweep for foreign agents. Either validate the token against a stored per-agent secret, or update the comment/README to accurately describe that only token presence is enforced (no real ownership guarantee).

4. 💡 Bug: Health sweep reports removals it never performs
   Files: app/api/registry/health/route.ts:12-26

   The health endpoint passes no-op callbacks to `computeSweep` (`applyOffline` only bumps a counter, `applyRemove` is `() => {}`), yet returns `stale_removed_last_run: sweep.removed.length` and a `removed` list. It reports agents as removed and excludes them from `online` even though nothing is actually removed or marked offline, so counts diverge from the real registry state and from what `GET /api/registry` would do. Either perform the same mutations as the listing route, or rename/document these as projected counts to avoid implying a mutation occurred.

Options

Auto-apply is off → Gitar will not commit updates to this branch.
Display: compact → Showing less information.

Comment with these commands to change the behavior for this request:

Auto-apply Compact
gitar auto-apply:on         
gitar display:verbose         

Important

Your trial ends in 6 days — upgrade now to keep code review, CI analysis, auto-apply, custom automations, and more.

Was this helpful? React with 👍 / 👎 | Gitar

Comment on lines +12 to +26
export async function GET() {
const agents = listAgentsForSweep()
let offline = 0

const sweep = computeSweep(
agents,
(agentId) => {
offline += 1
void agentId
},
() => {},
)

const online = agents.length - sweep.markedOffline.length - sweep.removed.length

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Bug: Health sweep reports removals it never performs

The health endpoint passes no-op callbacks to computeSweep (applyOffline only bumps a counter, applyRemove is () => {}), yet returns stale_removed_last_run: sweep.removed.length and a removed list. It reports agents as removed and excludes them from online even though nothing is actually removed or marked offline, so counts diverge from the real registry state and from what GET /api/registry would do. Either perform the same mutations as the listing route, or rename/document these as projected counts to avoid implying a mutation occurred.

Was this helpful? React with 👍 / 👎

@sonarqubecloud

Copy link
Copy Markdown

@leocagli

Copy link
Copy Markdown
Collaborator

Este quedó en conflicto con main recién ahora, por el merge del #497.

Los dos tocan el registro de agentes, así que era esperable. Un rebase sobre el main de
ahora y lo mergeo: el resto ya estaba bien, con los 7 checks obligatorios en verde
incluido SonarCloud Code Analysis.

Contexto por si sirve: en las últimas horas entraron el #498 (districts) y el #497
(leaderboard), los dos tuyos. El #494 lo actualicé yo y está esperando su CI.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants