Polling options
Options under polling — the pull-based ingress started by the-loop start when enabled is true (or run directly via python -m the_loop.daemon_entry poller), for hosts a webhook cannot reach: behind NAT or a firewall, a laptop, infrastructure with no inbound route.
Only the run loop is configured here. Everything the poller does with an item it discovers — spawning, session mapping, harness args, prompt templates, the guards — is reused verbatim from routing options. One dispatch stack, two ingresses.
repositories: [octo/repo] # top level — WHAT is polled (issue-348)
polling:
enabled: false
intervalSeconds: 60
maxRetries: 3
ci: { enabled: true, intervalSeconds: 300 } # read PR checks (issue-462)
sources:
- provider: github # HOW it is polled
monitor: { issues: true, pullRequests: true }
labels: [] # empty = reuse routing.autoExecuteLabelsThe run loop
enabled
- Type:
boolean - Default:
false
Whether the-loop start brings the poller up (issue-228, decision-084). Explicit, never inferred from a non-empty sources list — sources describes how to poll, not that polling is wanted on this host. start names this key when it skips a disabled poller, and reports an enabled poller with an empty sources list as misconfigured rather than starting a loop that would exit at once. Where the enabled poller runs — inside the service process (the default) or as its own — is service.hostIngresses (issue-231).
intervalSeconds
- Type:
integer - Default:
60
Seconds between poll cycles, across all sources.
Where the ledger lives — stateFile is gone
Which comments each item has already been forwarded is tracked in the poll section of that work item's record under state.root (<root>/portable/<work item>.json), so a comment is delivered exactly once across cycles and restarts. It is one record per work item now, not one file for the poller, which is why polling.stateFile was removed in issue-128 — a file path has nothing left to point at. A config that still sets it is refused rather than ignored; run the-loop migrate-config.
Nothing is re-forwarded on upgrade: a pre-issue-128 poll-state.json (or the pre-issue-106 .the-loop/poll-state.json) is read once per work item and written forward. See State on disk.
maxRetries
- Type:
integer - Default:
3
Per-event delivery attempts before the poller gives up. A spawn or comment forward whose dispatch keeps failing is retried each cycle up to this many attempts; after that the poller logs a terminal failure (poll.spawn_failed / poll.comment_failed) and ignores the event on later polls until new activity re-arms it. An in-flight, still-processing dispatch is not counted as a failed attempt.
Only deliveries that could still succeed are counted. An event the daemon refused on purpose — the work item is not started, its session is paused — or a comment that was a control keyword spends no attempt at all: it is resolved as poll.comment_settled and baselined, because no number of retries would change that answer, and nothing is replayed when the item is started or resumed (issue-270). The session reads the thread itself; the spawn prompt tells it to.
An abandoned comment is reported on the work item (issue-240). Giving up used to be visible only here, in the event log, and as a 😕 reaction — so somebody who told an agent to do something had no way to learn it was never told. The poller now posts one comment naming the abandoned comment, the attempts, and the recovery: post the instruction again, since a new comment carries a full retry budget and nothing on disk needs editing. It is marked as the-loop's own, so the poller never reads its own notice back. Best-effort in one direction only — no GitHub token, a non-GitHub provider or an API error logs poll.giveup_report_failed and the give-up stands regardless; a notice can never make an undelivered comment count as delivered.
An upgrade re-arms an abandoned comment, once (issue-146). A give-up is a statement about a failing environment, and a new the-loop version is the one event that can invalidate it — the reason those events could not be delivered may be exactly what the upgrade fixed. So the ledger records which version gave up, and the first time a poller running a different version sees that work item it un-resolves those comments with a full fresh budget (poll.rearmed), which is how an item stranded by a bug is picked up instead of staying stuck forever. Gated on the version rather than on "the poller started", so repeated poll --once runs from cron cannot re-forward abandoned comments every minute.
The two rules compose, which is how a comment lost to a the-loop bug comes back by itself: upgrading past the release that fixed it re-arms the comment, the delivery now works, and if it somehow still does not, the notice above says so on the ticket. Nothing on disk needs editing in either case.
CI monitoring
A webhook pushes CI results; a poller has to ask (issue-462). On its own clock, the poller reads the checks of every listed pull request that a live session owns. It hands each new completed result, failed or passed, to the dispatcher's CI gate. The gate judges it exactly as it judges a pushed one (routing.ci): a failure wakes the session with the healing section, and a pass resets the check's attempt count.
One read costs three requests per pull request: the pull request for its head commit, its check runs, and its combined status. A pull request with no live session is not read, because its result could reach nobody. The last result forwarded per check is kept in the pull request's poll ledger (ciSeen), so a result is forwarded once across cycles and restarts.
ci.enabled
- Type:
boolean - Default:
true
Read pull requests' checks on the poll ingress. false leaves a poll-only installation with no CI results, as before issue-462.
ci.intervalSeconds
- Type:
integer(at least 60) - Default:
300
Seconds between CI reads. This is separate from, and normally longer than, intervalSeconds, because each read costs three requests per pull request. The CI read happens inside a poll cycle, so it never runs more often than intervalSeconds.
Sources
sources is an ordered list. Each entry names a provider; the remaining keys are that provider's own config, unknown to the poller core — which is what keeps the core provider-agnostic. Two providers ship: github and, since issue-475, jira.
An empty sources list polls nothing
A directly run poller exits with no polling sources configured rather than looping quietly over an empty list, and the-loop start reports an enabled-but-sourceless poller as misconfigured without spawning one.
sources[].provider
- Type:
'github' | 'jira' - Default: none — required
Which poll provider handles this source. A github source polls every declared repository. A jira source polls the Jira projects it lists in projects (below), on integrations.jira.site.
sources[].labels
- Type:
string[] - Default:
[]
Labels gating what this source polls — every one required on an item (issue-381). Empty reuses routing.autoExecuteLabels, so one list drives both ingresses. Every label is passed to GitHub's listing filter, and the provider drops any listed item that does not carry the whole set, so what is tracked never depends on the filter's semantics. The single-string label this replaces is refused and migrated by the-loop migrate-config.
Which repositories are polled
Not here. Since issue-348 the repositories an instance works with are declared once, at the top level, and read by every ingress — see repositories. A source describes how to poll (provider, label, monitored entity kinds, binary); the top-level list describes what. Every github source polls every declared repository.
polling.sources[].repos was removed in that change. A config that still declares it makes the runtime refuse to start, naming the key and the command; the-loop migrate-config moves it.
One repository's failure is that repository's (issue-315, decision-106). Each repository is listed on its own — issues, then pull requests — and one that cannot be listed costs exactly that: the other declared repositories are polled as if it were not declared, the failure is recorded per repository (poll.scope_error, retried next cycle), and nothing in the failing repository is reconciled as closed, because a listing that did not happen proves nothing ended. It used to be the whole source: the first listing failure aborted the pass, and thirteen repositories stopped delivering because one had Issues turned off.
That one condition is classified as permanent — configuration drift, not a fault. A repository whose Issues are disabled (GitHub's own Issues are disabled for this repo) is surfaced once, as a warning (poll.scope_degraded), and then simply not asked for issues; its pull requests are still polled, since the pull-request listing does not need Issues. It is re-probed every 60 cycles (one hour at the default interval), on a config edit that reloads the sources, and on restart; a re-probe that answers logs poll.scope_recovered and the repository is polled normally again. Every other failure stays transient — retried next cycle, isolated, never quarantined. While anything is failed or skipped, the-loop status names it beneath the last cycle as degraded:.
sources[].projects
- Type:
string[] - Default: none — required when
providerisjira
The Jira project keys a jira source polls (issue-475). Each key must match ^[A-Z][A-Z0-9_]{1,9}$ and be listed under integrations.jira.projects with a repository. A mirror-only project (one with no repository) can be a jira@ room but never a work-item source, so polling one stops the poller at startup with an error that names it.
polling:
enabled: true
sources:
- provider: jira
projects: [PROJ]Each cycle runs one JQL query per project. The query lists the tickets that carry every arming label, in its Jira-safe form (the-loop: auto-execute becomes the-loop:auto-execute), and are not in the Done status category:
project = "PROJ" AND labels = "the-loop:auto-execute" AND statusCategory != Done ORDER BY updated DESCEvery value in the query is a quoted string literal, and no value from a ticket is ever placed in it. A project is its own scope. A rate limit (429) or a server error (5xx) degrades that project for the cycle and keeps the poll ledger's cursor, so on recovery no comment is dropped and none is delivered twice. A ticket moving to Done closes its work item, as a closed GitHub issue does. Setup and the identities that may act on a Jira ticket are in Webhook triggers.
sources[].monitor.issues
- Type:
boolean - Default:
true(github)
Poll issues.
sources[].monitor.pullRequests
- Type:
boolean - Default:
true(github)
Poll pull requests.
All three comment surfaces, not just the conversation
A polled pull request is read on the three surfaces GitHub files instructions under (issue-246): conversation comments, review bodies, and inline review-thread comments — the same set the webhook receiver has always handled. Each is forwarded exactly once, judged by its own author against routing.authorizedUsers, and an inline comment arrives with the file and line it is anchored to.
Two reviews carry no instruction and are not forwarded: an approval with an empty body, and a PENDING review its author has not submitted. A polled issue costs exactly the one request it always did; a polled pull request costs two more (the REST pulls/<n>/reviews and …/comments endpoints, every page).
Where the credential comes from
GitHub reads use the daemon's own token — the first set variable of integrations.github.api.tokenEnv (default GH_TOKEN, then GITHUB_TOKEN), configured once, not per source. A missing token is named by the-loop start's pre-flight. (Before issue-442 the reads went through the operator's gh; that binary is no longer used anywhere in the daemon.)
Hot reload
Edit sources or intervalSeconds while the poller runs and the change is picked up on the next cycle — no restart. An invalid edit is logged and the previous config kept. The shared dispatch config under routing still needs a restart.
Next
the-loop start— the command that runs the poller, and how finished work items get closed without aclosedwebhook.- Routing options — everything the poller reuses.