Requirements: poll ticketing/PR systems for labelled work and spawn/route sessions
Phase 1 of 3 (requirements → design → tasks). Ticket: issue #34. This phase should be reviewed and approved before moving to design.
Introduction
The webhook receiver (issue-15) is push: GitHub calls us. Sometimes it can't — the host running the harness sits behind a firewall/NAT, on a laptop, or on infrastructure GitHub cannot reach — so the events never arrive. This work item adds a pull ingress: a the-loop poll command that periodically asks each configured provider which of its work items carry the-loop's auto-execute label, then feeds them through the same routing/dispatch/session machinery the webhook receiver already uses.
The ingress is provider-agnostic (PR #45 review): the poller core and CLI carry no GitHub-specific knobs; a polling.sources config entry selects a provider by name, and the provider owns all provider-specific discovery and event construction. GitHub ships as one provider; the system interfaces with it only through config. The poller therefore owns only the agnostic ingress: discovery orchestration, an interval loop, and durable cross-poll dedup. Spawning, one-session-per-work-item, the tmux runner, harness adapters and prompt rendering are reused unchanged (decision-016/021).
Requirements
Requirement 1 — poll labelled work items and act on them
User story: As a developer whose harness host a webhook cannot reach, I want a command that polls my ticketing/PR system for work the-loop should act on, so that automation runs without an inbound webhook.
Acceptance criteria (EARS)
- WHEN
the-loop poll startruns THEN the system SHALL, everyintervalSeconds, ask each configured provider for the work items in its scope that carry the configured label. - WHEN a labelled item has no active registered session THEN the system SHALL spawn one for it via the existing
spawnOnUnmatchedpolicy and session registry. - WHEN a labelled item already has an active session THEN the system SHALL NOT spawn a second one — the session registry (one active session per work item) is the source of truth.
- WHEN
--onceis given THEN the system SHALL run exactly one poll cycle and exit (for cron/systemd timers); otherwise it SHALL loop until signalled to stop. - IF a provider's required tool (e.g. GitHub's
gh) is not available THENstartSHALL fail with an actionable error naming the missing dependency and SHALL NOT start the loop.
Requirement 2 — provider-agnostic, config-driven monitoring scope
User story: As a maintainer, I want the poller and its config to be agnostic of any specific system, with each provider (e.g. GitHub) reached only through configuration, so that no provider is hard-wired into the core.
Acceptance criteria (EARS)
- WHEN the poller decides what to monitor THEN it SHALL read
polling.sourcesfrom.the-loop/config.yaml; each entry SHALL name aprovider, and the remaining keys SHALL be that provider's own settings (opaque to the core). - WHEN no
polling.sourcesentries exist THENstartSHALL fail with a message telling the user to configure a source (nothing is polled by default). - WHEN the core, CLI flags, session registry, dedup state or run loop are examined THEN they SHALL contain no provider-specific vocabulary; provider specifics SHALL live only in the provider implementation selected by config.
- WHEN a source names an unknown or missing
providerTHENstartSHALL fail with a message listing the known providers. - WHEN a source omits its
labelTHEN the system SHALL fall back towebhooks.ghWebhook.routing.autoExecuteLabel, so one label configures both ingresses. - WHEN a GitHub source omits
reposTHEN the system SHALL fall back toticketing.github(owner/repo); IF none can be determined THEN listing that source SHALL fail with an actionable message.
Requirement 3 — reuse routing/dispatch and never duplicate sessions
User story: As the-loop, I want polled events to behave exactly like webhook events, so that there is one dispatch code path and one session per work item regardless of ingress.
Acceptance criteria (EARS)
- WHEN the poller acts on an item THEN it SHALL do so by handing a
RoutedEventto the existing dispatcher, reusing the session registry, harness adapters, runner (processortmux) and prompt templates fromwebhooks.ghWebhook.routing. - WHEN
routing.runneristmuxTHEN a polled spawn SHALL create a tmux-hosted interactive session, identical to a webhook-spawned one (attachable viathe-loop sessions attach). - WHEN a comment event maps to a PR whose head branch / closing keywords link an issue THEN it SHALL reach a session registered against either the PR or the linked issue (same work-item extraction as webhooks).
- WHEN a payload excerpt is rendered into a prompt THEN it SHALL be marked as untrusted data (reusing the existing templates), never as instructions.
Requirement 4 — durable, at-most-once comment forwarding
User story: As a work-item owner, I want a new comment on a labelled item to reach its session exactly once, so that polling does not replay old discussion or double-fire.
Acceptance criteria (EARS)
- WHEN the poller first sees an item THEN it SHALL baseline the item's current comments (record them as seen) WITHOUT forwarding them — matching webhook semantics where only activity after subscription is delivered.
- WHEN a comment newer than the recorded baseline appears THEN the system SHALL forward exactly one event for it to the matched session and record it as seen.
- WHEN the same comment is observed on a later cycle THEN the system SHALL NOT forward it again (dedup persists across cycles AND across process restarts via
stateFile). - WHEN the poll-state file is missing or corrupt THEN the system SHALL treat every item as first-seen (safe re-baseline) rather than crash.
Requirement 5 — lifecycle: start/stop like the receiver
User story: As an operator, I want to start and stop the poller the same way I manage the webhook receiver, so that operating both is uniform.
Acceptance criteria (EARS)
- WHEN
startruns without--onceTHEN it SHALL write its PID topidfileand remove it on exit. - WHEN
the-loop poll stopruns THEN it SHALL signal the recorded PID to shut down gracefully (draining in-flight dispatches), and report a stale/missing pidfile clearly. - WHEN the poller receives SIGINT/SIGTERM THEN it SHALL stop the loop, drain the dispatcher and exit 0.
Requirement 6 — hot-reload the polling config without a restart
User story: As an operator, I want to add/remove sources or change the interval by editing .the-loop/config.yaml while the poller runs, so that I don't have to restart it to change what is monitored.
Acceptance criteria (EARS)
- WHILE the poller is running, WHEN
.the-loop/config.yamlchanges THEN the system SHALL pick up the newpolling.sourcesandpolling.intervalSecondson the next poll cycle, without a restart (reload granularity is one cycle). - WHEN the config file is unchanged between cycles THEN the system SHALL NOT rebuild the providers (change is detected by content, not on every cycle).
- IF a reloaded config is invalid (parse error, unknown/missing
provider) THEN the system SHALL log the error, keep running with the previously loaded config, and retry on the next change — a bad edit SHALL NOT take the poller down. - WHEN there is no config file THEN the system SHALL run with its start-time configuration and simply have nothing to hot-reload.
Requirement 7 — hot-reload the webhook receiver's routing config too
User story: As an operator, I want the running gh-webhook receiver to pick up config edits the same way the poller does, so that I set up the dev box once and just edit config — no restart of the webhook server either.
Acceptance criteria (EARS)
- WHILE the receiver is running with routing enabled, WHEN
webhooks.ghWebhook.routing/eventschange THEN the system SHALL apply the new soft policy (events filter, auto-execute label, spawn policy, default harness, runner, spawn workdir, dispatch timeout, per-harness args, prompt templates) on the next received event, without a restart. - WHEN routing config is reloaded THEN the in-memory dedup cache, the per-session work queues and the session registry SHALL be preserved (a reload SHALL NOT replay events or drop in-flight work).
- IF a reloaded config is invalid THEN the system SHALL log it and keep the previous routing config (same tolerance as the poller).
- WHERE a setting is infrastructural — the listener bind (
host/port/path), the webhooksecretEnv,maxConcurrentDispatches,dedupCacheSize,registryDir, and thewebTerminal— changing it MAY still require a restart (documented), because it owns a socket, a subprocess, or live concurrency/dedup state.
Requirement 8 — only authorized users' actions are actionable (prompt-injection guard)
User story: As an operator, I want the-loop to act only on actions taken by users I authorize in config, so that a malicious comment/review/issue on a labelled work item cannot inject instructions into the agent. Each operator runs their own instance for their own logins.
Acceptance criteria (EARS)
- WHEN an event/action's responsible human actor (comment/review author, or the actor who labelled/opened an issue/PR) is not in the configured
authorizedUsersTHEN both the webhook receiver AND the poller SHALL ignore it — not forward it, not spawn on it. - WHEN an action has no identifiable human actor (e.g. a CI
workflow_run/check_*status) THEN the system MAY act on it (it carries status, not free-form instructions). - WHEN
authorizedUsersis empty THEN the system SHALL fall back toticketing.github.owner; IF that is also unset THEN the system SHALL ignore all human-authored actions (fail closed) and warn at startup. - WHEN the poller evaluates an item THEN it SHALL spawn only for items authored by an authorized user, and forward only comments authored by an authorized user; a dropped comment SHALL be recorded as seen so it is never re-evaluated.
- WHEN a
pull_requestclosedevent arrives THEN the receiver MAY act on it regardless of actor (it only auto-closes the-loop's own session and injects no content). - WHEN
authorizedUserschanges and the receiver hot-reloads THEN the new allowlist SHALL take effect on the next event.
Out of scope (this iteration)
- Non-GitHub provider implementations (e.g. Jira). The provider seam, config surface and
WorkItemRefare provider-agnostic and reserve thejira:provider, but only the GitHub provider is implemented here ("for now let's support polling github usinggh"). A new provider drops into the registry with no core changes. - Rebuilding live infrastructure on reload — see R7.4: the listener bind,
secretEnv,webTerminaland the dispatcher's threads/dedup/registry stay start-time. The dispatcher owns worker threads and the in-memory dedup, so it is established once at start. Only thepollingblock hot-reloads. - PR review (inline code) comment threads. Conversation comments on issues and PRs are covered; review-thread polling is a follow-up.