Design: Endpoint Presence
Context
A doorbell becomes a model turn (ADR-0013), and Switchboard has no idea whether an endpoint's agent is at work. The result is re-rings spent on agents that are gone, doorbells for agents that are winding down, and nothing on their return. ADR-0027 adds per-endpoint presence (in or out), set by the agent, by its human, or by a shift. It also adds a digest doorbell on return, and a delivery-accounting fix in the heartbeat sweep. Governing spec: SPEC-0022. Amended: SPEC-0011, SPEC-0006, SPEC-0007.
Goals / Non-Goals
Goals
- A disconnected or clocked-out agent spends no ring budget and receives no pushes.
- A returning agent learns its backlog in one doorbell.
- Every existing endpoint works unchanged and gains the verbs without a re-vend.
- Correct across instances and restarts without in-memory coordination.
Non-Goals
- Changing what an agent may pull or claim.
- Routing work to whichever endpoint is awake (deferred in ADR-0027).
- Knowing whether an agent is mid-turn.
- Coupling to Harness or any other supervisor.
Decisions
Columns on endpoints, no new table
Migration 0021_endpoint_presence.sql:
ALTER TABLE endpoints
ADD COLUMN shift text,
ADD COLUMN presence_override text CHECK (presence_override IN ('in','out')),
ADD COLUMN override_until timestamptz,
ADD COLUMN override_set_at timestamptz,
ADD COLUMN override_by text,
ADD COLUMN presence_seen text NOT NULL DEFAULT 'in' CHECK (presence_seen IN ('in','out')),
ADD COLUMN presence_changed_at timestamptz,
ADD COLUMN presence_changed_by text;
override_set_at is what "ended by the next boundary" is computed from: an override is live only if
no shift boundary lies between override_set_at and now. Presence is one row per endpoint, read
wherever the endpoint is already read, so a separate table would only add a join.
A pure internal/presence package
type Shift struct { /* zone + windows */ }
func ParseShift(s string) (Shift, error)
func (s Shift) In(t time.Time) (in bool, next time.Time, ok bool)
type Inputs struct {
Shift *Shift
Override string // "", "in", "out"
OverrideUntil *time.Time
OverrideSetAt time.Time
}
// Effective applies SPEC-0022 REQ "Presence Model".
func Effective(in Inputs, now time.Time) (presence string, source string, until *time.Time)
Everything that decides presence, including the doorbell filter, the sweep, the verbs, the API and
the evaluator, calls Effective, so the precedence rule exists in one function with a table test.
The grammar mirrors Harness's internal/hours parser. The shared contract is the grammar string, not
the code: the two projects do not share a module.
Filtering the doorbell
Handler already caches each session's endpoint auth snapshot. It gains a small presence cache,
map[endpointID]presence.Inputs, loaded on session creation and refreshed from a
LISTEN endpoint_presence notification that every presence write sends. PublishTodoReady computes
presence.Effective(inputs, now) right after its tenant check and returns early when the result is
out. The 5-second cross-instance freshness bound comes from the NOTIFY. The 30-second evaluator
re-reads the cache as a safety net for a missed notification.
The heartbeat sweep takes the instance's receivers
RingUnclaimed(ctx) becomes RingUnclaimed(ctx, receivers []string). receivers holds the endpoint
IDs that have at least one session attached to this instance (streaming or idle, matching what
PublishTodoReady will accept) and whose effective presence is in; the Handler builds that list.
The SQL adds AND t.endpoint_id = ANY($8). An instance with no receivers skips the query. Rings are
still counted in the same statement that picks them, but now only for an endpoint that could receive
one. A buffer-full drop can still waste a ring, which the existing lossy contract accepts.
The list MUST be built from attached sessions, not from open streams alone. PublishTodoReady
prefers a streaming session but falls back to a streamless one, and the transport only drops that
push if the stream is still closed when it is written. An endpoint whose only session is momentarily
streamless is thus still rung — so excluding it from receivers would mean no sweep ever counts its
todo, and with nothing else to re-arm it the backoff silently becomes no ring at all. That is
precisely the lost-ring failure (SPEC-0011 REQ "Doorbell Heartbeat") this fix exists to remove, so
the narrower predicate would reintroduce it.
Transitions: claim in SQL, deliver locally
A presence evaluator runs every 30 seconds on each instance and immediately after any presence write:
UPDATE endpoints SET presence_seen = $new, presence_changed_at = now(), presence_changed_by = $src
WHERE id = $id AND presence_seen <> $new
RETURNING id;
Only the instance whose update returns a row broadcasts NOTIFY endpoint_presence '<id>:<new>'. Every
instance that receives an in broadcast and hosts an open-stream session for that endpoint sends it
one digest. That means one digest per hosting instance. Sessions of one endpoint are competing
consumers of one agent, so two instances each waking one worker is the same outcome as two todos
arriving.
The digest
type Digest struct {
Pending int
Queues map[string]int
Oldest time.Time
Reason string // clock_in, operator, shift_start, override_end, reconnect
}
It is built from a PendingDoorbellTodosSummary(endpointID) query using the push-eligibility
predicate already in RingUnclaimed, grouped by queue. It is encoded as a
notifications/claude/channel with content as one line, and meta = kind=digest, pending,
queues (comma-joined, sorted), reason, all snake_case. After a successful write,
MarkDigestRung(endpointID, ids, at) sets last_ringed_at on exactly the counted rows.
Reconnect digests
mcpSession already tracks inflight streams. The first 0→1 transition for an endpoint on an
instance, with no other open-stream session for it on that instance, triggers a digest check through
the existing doorbellGate keyed digest:<endpointID>, with a 10-minute TTL.
Self verbs
internal/mcp/verbs.go gains SelfVerbs() []string { return {"clock_in","clock_out","presence"} }.
tools/list appends them after the scope-derived set, and tools/call checks
hasScope(ep.ScopeVerbs, v) || isSelfVerb(v). The vend wizard and OAuth consent screen do not list
them, because they are not grantable.
Architecture
Risks / Trade-offs
- Exempt verbs. The allowlist stops being the complete list of callable verbs. Mitigated by keeping the exempt set to three verbs with no arguments that could address anything, and by a test that fails if any self verb accepts an identifier.
- Two clocks. Harness
operating_hoursand the endpointshiftcan disagree. Mitigated by using the same grammar, and by the reconnect digest, which makes a missing shift degrade to "it works, a little less efficiently." - Missed NOTIFY. An instance could keep a stale presence for up to 30 seconds. The cost is at most one extra ring or one lost digest: a missed broadcast is never re-delivered, so the digest guarantee is at-most-once, not at-least-once (SPEC-0022 REQ "Clock-In Digest").
- Consumers assuming
todo_id. Agents told by the server instructions to claimmeta.todo_idmust learn the digest. Theinstructionstext oninitializechanges to describe both doorbells.
Migration Plan
Additive columns with defaults that preserve today's behavior (presence_seen = 'in', no shift, no
override). The sweep signature change is internal. Roll back by dropping the columns after reverting
the code. No data depends on them.
Open Questions
- Should the digest name the oldest todo's id, so an agent that wants FIFO can claim it directly, or stay count-only so it never carries anything todo-derived?
- Should the 8-hour cap on an agent's
clock_inbe configurable per endpoint by its human?