Skip to content

fix(registry): heartbeat picks up member-registered agents — write-side seed + read-side CTE#4252

Merged
bokelley merged 1 commit into
mainfrom
EmmaLouise2018/heartbeat-picks-up-member-agents
May 9, 2026
Merged

fix(registry): heartbeat picks up member-registered agents — write-side seed + read-side CTE#4252
bokelley merged 1 commit into
mainfrom
EmmaLouise2018/heartbeat-picks-up-member-agents

Conversation

@EmmaLouise2018

Copy link
Copy Markdown
Contributor

Closes part of the visibility-miss bug class fixed in #4235 (different table, same shape).

Summary

Agents registered via POST /api/me/agents or save_agent lived in member_profiles.agents JSONB but never landed an agent_registry_metadata row. The compliance heartbeat's known_agents CTE unioned only discovered_agents and agent_registry_metadata, so member-registered agents were invisible to the heartbeat. agent_compliance_status stayed empty, /api/registry/agents/<url>/compliance returned status: "unknown" forever regardless of the 12h cycle.

Why

Caught on the same agent that motivated #4235 (harvingupta.xyz/api/mcp). Pod audit confirmed: agent_registry_metadata 0 rows, agent_compliance_status 0 rows, discovered_agents 0 rows — but agent_capabilities_snapshot and agent_health_snapshot had 1 row each (other probes ran). The heartbeat's enqueue query keys off the wrong source for member-registered agents.

Fix shape — write-side seed + read-side CTE

  • Write-side seed. applyMemberAgentMutation (the helper backing POST /api/me/agents and PATCH /api/me/agents/:url) bulk-upserts agent_registry_metadata rows for every URL in the resulting agents array, atomically with the JSONB write. ON CONFLICT DO NOTHING preserves owner-customized lifecycle_stage / check_interval_hours / compliance_opt_out. The save_agent MCP handler does the same upsert after its profile write; failures are logged at warn and don't fail the registration.
  • Read-side defense in depth. The known_agents CTE in getAgentsDueForCheck gains a third leg unioning member_profiles.agents URLs. Any agent that slipped past the seed is still picked up by the heartbeat. ORDER BY adds ka.agent_url ASC as a deterministic tiebreaker so two never-checked agents land in stable order across runs.

Behavior change

Heartbeat will start picking up previously-orphaned member-profile agents on its next cycle — brief surge of compliance runs after deploy as the backlog drains. Per-agent rate limits and the heartbeat's per-run limit cap (default 10) bound the surge.

Migration

Manual backfill script for existing rows (out of release_command so it doesn't auto-fire on deploy):

fly ssh console -a adcp-docs
cd /app && cat > /app/migrate-metadata.mjs <<'SCRIPT'
import pg from 'pg';
const DRY_RUN = process.argv.includes('--dry-run');
const c = new pg.Client({ connectionString: process.env.DATABASE_URL });
await c.connect();
const candidates = await c.query(`
  SELECT DISTINCT (a->>'url') AS agent_url
  FROM member_profiles, jsonb_array_elements(agents) a
  WHERE a->>'url' IS NOT NULL
    AND (a->>'url') NOT IN (SELECT agent_url FROM agent_registry_metadata)
  ORDER BY agent_url
`);
console.log(`${candidates.rowCount} URL(s) need a metadata row`);
for (const row of candidates.rows) {
  if (!DRY_RUN) {
    await c.query(`INSERT INTO agent_registry_metadata (agent_url) VALUES ($1) ON CONFLICT DO NOTHING`, [row.agent_url]);
  }
}
await c.end();
SCRIPT
node /app/migrate-metadata.mjs --dry-run  # review
node /app/migrate-metadata.mjs            # commit

Refs

Test plan

  • tsc --noEmit -p server/tsconfig.json clean
  • Build passes
  • 3 new integration tests in member-agents-api.test.ts:
    • POST seeds an agent_registry_metadata row when none exists
    • POST does NOT overwrite an existing metadata row on re-register (lifecycle_stage = 'testing' and check_interval_hours = 24 survive)
    • getAgentsDueForCheck picks up an agent that lives only in member_profiles.agents (read-side CTE in isolation, with metadata row deliberately absent)
  • Integration tests in CI (require live Postgres)
  • Smoke after deploy: register a fresh agent, wait one heartbeat cycle, confirm /api/registry/agents/:url/compliance returns a real verdict (not "unknown")

…e seed + read-side CTE)

Agents registered via POST /api/me/agents or save_agent live in
member_profiles.agents JSONB but never landed an agent_registry_metadata
row. The compliance heartbeat's known_agents CTE unioned only
discovered_agents and agent_registry_metadata, so member-registered
agents were invisible to the heartbeat — agent_compliance_status stayed
empty, /api/registry/agents/<url>/compliance returned status:"unknown"
forever, regardless of the 12h cycle.

Fix shape — both write-side and read-side, mirrors PR #4235.

- Write-side: applyMemberAgentMutation and save_agent both upsert
  agent_registry_metadata atomically with the JSONB write. ON CONFLICT
  DO NOTHING preserves owner-customized lifecycle / interval / opt-out.
- Read-side defense in depth: known_agents CTE in getAgentsDueForCheck
  gains a third leg unioning member_profiles.agents URLs. ORDER BY adds
  agent_url tiebreaker.

3 new integration tests pin the contract: POST seeds metadata when none
exists, POST preserves customized metadata on re-register,
getAgentsDueForCheck picks up an agent that lives only in
member_profiles.agents (read-side CTE in isolation).

Manual migration run on the pod backfills agent_registry_metadata rows
for existing member-profile agents that have no metadata row.
@bokelley bokelley merged commit 79f5a1f into main May 9, 2026
15 checks passed
@bokelley bokelley deleted the EmmaLouise2018/heartbeat-picks-up-member-agents branch May 9, 2026 00:02
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