Skip to content

M2-20 Heartbeat, presence and configured versus reported billing mode

No Date Ruling Reason
D-M2-20-0 2026-10-20 Base int/m1-wave49 at eb15f40, branch feat/M2-20-heartbeat-presence in its own worktree; not merged locally and no pull request from this build; the full verification runs on the build branch. The contract the brief needs (the agents columns of 03, agent.presence_timeout of 04, agent.heartbeat and agent.presence_changed of 05, the openapi schemas) is on the base, so nothing is ported; the base names the chapters in German, and text this ticket adds there is English. Differences to the brief's names, following the merged code (D-W-100): domain.ReportedChange(stored, next Reported) reads the evidence from next.Evidence (Reported gained Evidence); the brief's GetTargets is GetPresenceRows, one query for the configured values and the stored presence; kernel/clock has no timers, so the deadline timers and the flush loop run on the port app.Timers (time.AfterFunc, a fake in tests, the pattern of terminal/app); the metrics port is Tracker.Samples(), read by the observable gauges of internal/agents/adapters/otelmetrics over the registry specs (no separate no-op port); the snapshot takes AgentPresence from the agent listing, whose presence comes from the tracker; the field type agents.PresenceStatus replaces the earlier struct agents.Presence, whose name is now the port; the change path stores the heartbeat time of the clock in last_heartbeat_at. A person who is no owner and an app get 404 not_found from sendHeartbeat (and error with not_found over the gateway), because the resource is the agent itself and 404 comes before 403 (R-AZ-01, D-M2-07-12); the brief's 403 forbidden with subject_kind_not_allowed is what the owner gets. The row D-G-280 the brief names does not exist on the base or on origin/main; the placeholder unknown it means is ruling M1-63/3, which names the end of its presence part. The task names the base and the lane; the merged names win (D-W-100).
D-M2-20-1 2026-10-20 One decoder (domain.DecodeHeartbeat) and one use case (Tracker.Heartbeat behind the port agents.Presence) serve both transports: the REST handler SendHeartbeat encodes the checked body again, the gateway passes the raw member agent. The presence lives in one process-wide tracker in agents/app, started only in the roles api and all; it publishes through kernel/eventlog.EphemeralPublisher and reaches project readiness only through the chat port. Place: internal/agents/presence.go, internal/agents/app/presence.go, internal/agents/app/heartbeat.go. sendHeartbeat, agent.heartbeat, the table agents and agent.presence_changed belong to agents, the op heartbeat to gateway, which may use agents (02 4.4, 4.6, 4.7); only api and all receive heartbeats (02 3.2), and one mapping keeps both transports equal.
D-M2-20-2 2026-10-20 agent_heartbeat.v1 is closed: the top-level members of the openapi schema Heartbeat and the details keys capabilities (followups steer or queue; components, voice_input, title, files_out booleans), active_runs (distinct UUIDs), degraded_reasons (distinct codes), adapter (name, optional version), runtime (name), protocol_version (positive integer); any other key, also inside capabilities, adapter and runtime, is an unknown member. Forms: names ^[a-z0-9][a-z0-9._-]*$, versions ^[0-9A-Za-z][0-9A-Za-z.+_-]*$, codes ^[a-z0-9][a-z0-9_.:-]*$; no duplicates in the lists and among the channel_id values of project_readiness (pointer /project_readiness); rate_limit is not_enabled until M6-14. runtime_version is the runtime's version (reported_runtime_version), details.adapter.version the adapter's (18 9 point 5). The domain checks every rule itself on both transports. Place: internal/agents/domain/heartbeat.go, 09 4.8. 09 4.8 forbids secrets, contents and paths; these forms make path separators, whitespace, control and bidi characters and most key formats unrepresentable, and the code form of SEC-AUD-5 lets method enter the audit unchanged (SEC-IN-1).
D-M2-20-3 2026-10-20 Full state: every heartbeat replaces the whole reported state; an absent optional member means "not reported" (reported_billing_mode and reported_tool_class NULL, the key absent in reported_details), except project_readiness. A change of the reported mode or class against the stored value, also the first report ever and a change from or to "not reported", runs one unit of work: lock the row, compare again, write both columns with the whole reported state and record agent.heartbeat allow with only billing_mode_old, billing_mode_new, tool_class_old, tool_class_new (each only when it exists) and key_type and method; one entry per heartbeat, also when both columns change; equal after the lock writes nothing. Only this path writes the two audited columns. Place: internal/agents/app/heartbeat.go, queries/presence.sql (LockPresenceRow, WriteReportedChange). 09 4.7 "last one wins"; the reported mode is the evidence of I3, so its history must be checkable (10 8, 14), and the row lock lets exactly one process audit a change.
D-M2-20-4 2026-10-20 offline holds at and after the last heartbeat plus agent.presence_timeout (inclusive boundary). agent.presence_changed is published exactly when state or reported_billing_mode differ from the last published pair of the entry (initially the stored values); the last reported mode stays in the payload while offline and is left out when not reported. Place: domain.StateAt, Tracker.publish. One boundary rule for timer and tests; the pair covers the payload AgentPresencePayload, and 03 names the "last reported mode", so clients keep showing it while the agent is away.
D-M2-20-5 2026-10-20 The flush loop writes every dirty entry at most once per agent.heartbeat_persist_interval, ascending agent id, in one unit of work, with the monotone PersistHeartbeat (last_heartbeat_at IS NULL OR last_heartbeat_at <= $hb); an entry is clean afterwards either way, and the offline write uses the entry's last heartbeat time. The stop step agent_presence in the phase of the connections, after the gateway's step, stops the timers without publishing and flushes within the rest of shutdown.total_timeout. Place: internal/agents/app/presence_flush.go, 02 15.3. A fresher heartbeat stored by another process is never overwritten (OQ-07: node-neutral code); a restart of the server is not an agent going offline.
D-M2-20-6 2026-10-20 Start rule: the tracker loads the agents stored online or degraded; a missing last_heartbeat_at or one older than agent.presence_timeout plus agent.heartbeat_persist_interval is offline at once, the others keep their state until the start plus agent.presence_timeout. Agents not loaded get an entry with their first heartbeat. Place: domain.StartRule, Tracker.Start. The stored value lags the real last heartbeat by at most the persist interval, so an older value proves no heartbeat within agent.presence_timeout; the grace gives adapters time to reconnect after 4013.
D-M2-20-7 2026-10-20 Difference kinds while online or degraded: (a) reported_billing_mode present and unequal to billing_mode; (b) reported_billing_mode absent, billing_modes_supported reported without billing_mode; (c) billing_evidence.key_type = unknown. Warning codes of AgentRuntimeStatus.warnings: billing_mode_mismatch only after (a) has held for longer than billing.mismatch_alert_after, billing_mode_unsupported (b) and billing_evidence_unknown (c) at once, tool_class_mismatch for a reported tool class unequal to tool_class; no warning and no difference while offline or unknown. Place: domain.Assess, Assessment.Warnings. 10 8 names the duration only for row 1; an undeterminable mode counts as unequal to every configured mode (10 7); an agent that is not present reports nothing current.
D-M2-20-8 2026-10-20 New gauge bothy.agent.billing_mode.mismatch_age (s, bothy.agent.handle): per active tracked agent the seconds since a difference of any kind of D-M2-20-7 began, 0 without one. The rule BothyBillingModeMismatch keeps its counter expression and adds or max by (bothy_agent_handle) (bothy_agent_billing_mode_mismatch_age_seconds{job="bothy/bothy-server"}) > the default of billing.mismatch_alert_after in seconds as the last comparison; severity page. Place: signals.md 4.3, bothy-alerts.yaml, alerting.md 3.2. The catalogue had no signal for the heartbeat difference (spec gap); the alert-thresholds check reads the final comparison, and 10 8 row 1 asks for an alarm after the duration.
D-M2-20-9 2026-10-20 The gateway op heartbeat keeps gateway.connect and the liveness rules of M1-64 first (heartbeat_ack, reset of the 4009 deadline); then, with member agent, rl.agent_runtime keyed by the principal and agents.Presence.Heartbeat. Errors of the agent part answer error {op: "heartbeat", code} and keep the connection; a heartbeat of an agent without member agent changes no presence. Place: internal/gateway/app/heartbeat.go. Liveness must not depend on the content of the report; presence needs the state report, and 04 12 item 2 "implicitly through the gateway heartbeat" refers to this member.
D-M2-20-10 2026-10-20 Project readiness is applied only when the member is present and the list differs from the last list this process applied for the agent, through ApplyProjectReadiness of the chat port in its own unit of work before the transaction of D-M2-20-3; any other error of the port fails the heartbeat with 500 and changes no presence. Place: Tracker.applyReadiness. No transaction holds rows of two modules (03 6.3), and a repeated list costs no write.
D-M2-20-11 2026-10-20 HeartbeatAck.interval_ms is sync.heartbeat_interval, the interval the gateway announces in hello. The start fails when agent.presence_timeout is not greater than sync.heartbeat_interval, with a message naming both keys (cross-check presence_timeout_above_heartbeat_interval). Place: internal/wire configuration checks, app.NewTracker. An adapter that follows interval_ms would otherwise flap to offline between two heartbeats.
D-M2-20-12 2026-10-20 AgentRuntimeStatus gains the members billing_evidence ($ref: BillingEvidence) and warnings (array of codes, with the known values of D-M2-20-7, the pattern of AuditVerifyResult.warnings). Place: docs/api/openapi.yaml. The agent card shows the evidence and the warnings of 10 8 and 13 without a second request; codes keep the clients' texts in their own languages.
D-M2-20-13 2026-10-20 The metric registry gets the attribute source agent_handles: a value of the handle form of identity (^[a-z0-9][a-z0-9._-]*$) is admitted instead of a closed list; every other source stays a list. Place: internal/platform/otel/metrics. Agent handles are a set the owner limits (signals.md 2 item 1), but they are not known when the instrument is built.
D-M2-20-14 2026-10-20 At start, agents stored live whose principal is not active are loaded too, but inactive: no metric series, their deadline runs. Place: Tracker.Start. A disabled agent stored online becomes offline instead of staying online in its row.
D-M2-20-15 2026-10-22 Port onto main from the ticket branch (eb15f40..cb2a50f, built on int/m1-wave49) after M2-07 and M2-03: the ticket's own changes are applied on current main, the generated REST server, Go client, Dart, Python and TypeScript clients, sqlc code and config reference are regenerated from the merged contract, and the text the ticket added to the spec chapters, READMEs and operations documents is carried into their English versions on main (chapter names 09-agents.md, 10-backend-policy.md and so on). The integration fix INT-W51/14 (presence tracker held by the restore start lock of M7-04) is not ported, because M7-04 is not on main; whoever lands M7-04 wraps the step agent_presence in whileFinalized like the log tail. Each ticket lands on main on its own; only the ticket's own commits come along.