Decision 022: poll as a provider-agnostic pull ingress reusing the webhook dispatch stack
- Status: accepted
- Date: 2026-07-21
- Deciders: @MadaraUchiha-314 (issue #34, PR #45 review)
- Work item: issue-34
- Spec:
docs/specs/issue-34/
Context
The webhook receiver (issue-15, decision-016) is push-based: GitHub must be able to reach the host. When it can't — the harness runs behind NAT/a firewall, on a laptop, or on infrastructure GitHub cannot call — events never arrive. Issue #34 asks for a polling ingress that watches label-gated issues/PRs and spawns/routes tmux sessions (issue-32), without spawning duplicate sessions for the same work item.
PR #45 review sharpened the shape: "we are indexing too much in GitHub being the only source of work-item dispatch — make the config and other systems agnostic of GitHub; the only way the system interfaces with GitHub is through config."
Decision
Add the-loop poll start|stop, a provider-agnostic pull ingress that reuses the entire webhook routing/dispatch stack rather than reimplementing any of it. A PollProvider seam abstracts discovery + event construction; a polling.sources config entry selects a provider by name (GitHub ships as GitHubPollProvider), and the poller core, CLI, session registry, dedup state and run loop carry no provider-specific vocabulary. Each cycle a provider lists its labelled work items, synthesises the same RoutedEvent shape the receiver produces, and the poller hands it to the existing Dispatcher. Consequences:
- One session per work item is inherited, not re-solved. The session registry is the dedup authority; the poller emits a spawn ("presence") event only while a work item has no active session, so a live session is never doubled and a failed spawn simply retries next cycle.
- Spawn vs. comment are separate synthetic events. Presence events carry
labeled=True(drivespawnOnUnmatched); comment events carrylabeled=False(route to an existing session, never spawn). This keeps spawning registry-idempotent and comment forwarding exactly-once independently. - Provider seam keeps the core agnostic. A
PollProvider(inpoller/base.py) owns discovery + event construction; the poller core speaks onlyWorkItem/Commentand the sharedRoutedEvent.GitHubPollProvider(poller/github.py) is the only GitHub-aware code. Apolling.sourcesentry'sprovidername selects the class via a registry; a new provider (e.g. Jira) drops in with zero core changes. - Config is provider-agnostic.
pollingholdsintervalSeconds,stateFileand asourceslist; each source names aproviderplus that provider's own keys (a GitHub source:repos,monitor,label,ghBinary). The CLI (the-loop poll) exposes only run-loop flags — no provider knobs. GitHub is interfaced with only through a configured source. - Dispatch config is reused from
webhooks.ghWebhook.routing(harness, runnerprocess/tmux, spawn policy, templates, registry). A source'slabeldefaults to the routingautoExecuteLabel; a GitHub source'sreposfalls back toticketing.github. ghis the GitHub provider's client, inheriting the user's existing auth — no token of our own, consistent with how the-loop shells out toghelsewhere.ghis verified at start (per-providercheck_dependencies) like tmux/ttyd.- A durable
PollStateowns cross-poll/restart dedup. With no webhook redelivery to lean on, the poller is the reliability layer: it baselines an item's comments on first sight (no replay) and forwards each new comment once, surviving restarts via.the-loop/poll-state.json. - The
pollingconfig hot-reloads (PR #45 review) at one-poll-cycle granularity: aReloadercontent-hashes.the-loop/config.yamland rebuilds the provider/interval plan on change — no restart to add/remove sources or retune the interval. A bad edit is logged and the previous plan is kept. - The
gh-webhookreceiver hot-reloads too (PR #45 review):Reloaderis a shared primitive (the_loop/reload.py), and the event-driven receiver checks it on each received event (non-blocking lock; the HTTP server is threaded). On change it hot-swaps the soft routing policy viaDispatcher.reload(config, adapters, templates) plus the router'sevents/autoExecuteLabel. The dispatcher's worker queues, concurrency semaphore, dedup cache and session registry are preserved — rebuilding them would replay events or drop in-flight work — so those, plus the listener bind /secretEnv/webTerminal, remain restart-only (documented).
Only the GitHub provider is implemented now; jira: stays reserved in WorkItemRef and the seam. PR review (inline) comment threads are a follow-up — conversation comments are covered.
Consequences
- New package
cli/the_loop/poller/:base.py(thePollProviderseam —WorkItem/Commentplus the registry),github.py(GhClient/GitHubPollProvider), andpoller.py(the agnosticPoller/PollState/PollConfig). Plus commandpoll; new provider-agnostic configpolling.sources; new git-ignored runtime statepoll-state.json/poll.pid. No changes to the webhook, dispatcher, registry, runner or adapters — polling is additive. - Two ingresses (push webhook + pull poll) can even run together against one registry; the shared deduper/registry keep them from stepping on each other.
- Polling latency is bounded by
intervalSeconds(vs. near-instant webhooks) and costs provider API calls per cycle — the accepted trade for reaching unreachable hosts. - Re-evaluation triggers: adding Jira/other providers (implement
PollProvider, register it); needing PR review-thread events (extend the GitHub provider); a provider API gaining a first-class "since" cursor for comments (drop the state-file baseline for that provider).
Alternatives considered
- Hard-wire GitHub into the poller/config — rejected on the PR #45 review: it indexes the whole dispatch story on one provider; the seam keeps GitHub reachable only through config and admits Jira/others later.
- Reimplement spawning/dedup in the poller — rejected: duplicates the tested registry/dispatcher invariants and risks a second, diverging "one session per item" code path.
- Generalise the webhook router/dispatcher too (full-stack agnostic) — deferred: reopens merged issue-15 code and balloons this PR; the shared
RoutedEvent/registry are already provider-keyed, so the provider builds them and the downstream stays as-is. - Query GitHub via the MCP server / REST with our own token — rejected:
ghalready carries the user's auth and enterprise config and is how the-loop talks to GitHub elsewhere; a wheel needs no extra credential story. - A shared cursor (max
updatedAt) instead of per-comment ids — rejected: second granularity and edits make it lossy; storing seen comment ids is exact and simple. - Long-poll / GitHub Actions push from CI — rejected for this iteration: still needs an inbound path to the harness host, which is the very thing that is missing.