Skip to content

Hosted GitHub source: take GitHub records off the channel streams instead of polling - #539

Merged
pufit merged 34 commits into
mainfrom
pufit/hosted-github-source
Oct 21, 2026
Merged

pufit merged 34 commits into
mainfrom
pufit/hosted-github-source

Conversation

@pufit

@pufit pufit commented Oct 21, 2026

Copy link
Copy Markdown
Member

Hosted GitHub source: take GitHub records off the channel streams instead of polling

Stacked on #538 (activity reporter). In hosted mode the platform behind the gateway polls GitHub for the agent and delivers records over the same hosted stream that hosted Slack uses; Nerve stops polling GitHub itself.

  • sync.github.mode: poll | hosted (default poll, restart-only), sync.github.pr_watch, cron.delivery_debounce_seconds.
  • Contract: Event.record (typed source record with metadata entries) for the source_record kind; a one-way subscriptions frame that publishes this agent's GitHub filters and pr_watch right after negotiation and on every configuration reload ({"version":"1"} when GitHub is disabled = subscribes to nothing). Filter semantics are Nerve's own (fnmatch, deny wins, allow fails closed), with shared test vectors; PR-activity records are matched against the repository rules only.
  • A consumer for provider github writes each record to the channel buffer and acknowledges only after the durable write; the github source drains the buffer with the existing runner (guardrails, persistence, condensing, drop hook, health, /api/sources/* and the inbox UI unchanged), so run_if: [{type: messages, sources: [github]}] keeps working.
  • CronService.on_source_delivered(source): drains the runner at once, then runs the jobs gated on that source once per burst after the debounce; never overlaps a scheduled run; cancelled cleanly by stop().
  • In hosted mode the github_pr_activity gate passes without gh (the platform watches the PRs); nerve doctor and the setup page stop asking for gh when nothing needs it; the setup writer and nerve init keep the gh-based github_events source off while GitHub is hosted.
  • The first subscriptions frame after negotiation waits out a stalled stream (only the reload republish is time-bounded), and a frame that waited carries the settings from after any reload in that window. A messages gate keeps its live cursors renewed while it checks them.
  • Docs: docs/sources.md (Hosted GitHub source, switching back to poll safely), docs/cron.md, docs/config.md.

Rollout: the gateway must know the subscriptions frame before an agent turns hosted mode on (an unknown frame closes the stream). Known limitation (documented, nerve doctor warns): with hosted Slack configured but switched off, hosted GitHub records wait until it is switched on again.

Tests: 122 new (contract, shared glob vectors, fake gateway with record events + captured subscriptions, end-to-end event → buffer → runner → inbox → gated job, setup writer/nerve init, stalled first frame). Full suite 6,952 passed, 39 skipped; vitest 999; ruff and lint unchanged.

Generated by Nerve

🤖 Generated with Claude Code

pufit added 30 commits October 21, 2026 01:33
sync.github.mode chooses between polling GitHub with the gh CLI and
taking the records that the channel gateway polls. It is restart-only
and parsed like slack.mode: an unknown value falls back to poll with a
warning. sync.github.pr_watch asks the gateway to watch the agent's own
open pull requests. cron.delivery_debounce_seconds (0-600, default 60)
spaces the job runs that a hosted delivery triggers.

A reload publishes the hosted source subscriptions to the gateway again
after the source runners are rebuilt.
A source_record event carries one record of a hosted source in a typed
record member: id, record type, summary, content, timestamp, and
metadata entries that hold a string or a list of strings. Its check
requires the id, type and summary and bounds the text by the stream's
text limit.

The new subscriptions frame goes one way from Nerve to the gateway and
names the agent's hosted sources with their filter patterns and the
pull request watch. Nerve checks the gateway's bounds before it sends
one, and can drop the patterns when a subscription would not fit.
With sync.github.mode set to hosted, the hosted runtime starts also
without a hosted chat provider and gives the inbox reader a consumer for
the provider's source records. The consumer writes each record to the
channel_observations buffer under the provider's name and acknowledges
it once the row is stored; it is not a channel, so the router never
sees it. A delivery listener hears of every stored record.

Each stream publishes the agent's subscriptions once it has negotiated,
built from sync.github: the guardrail patterns as filters and pr_watch.
A switched-off source subscribes to nothing. A list the gateway would
refuse is published wider, never narrower, so the gateway may store a
record the inbox filter still drops but never drops one it keeps.

The fake gateway builds source record events and keeps the subscriptions
frames it receives.
The notification record builder and the GitHub inbox filter move out of
GitHubSource.fetch and the registry into github.py, so the polling and
hosted paths share them. In hosted mode the registry builds
HostedGitHubSource instead of GitHubSource: it reads the buffered
records and maps each payload to a SourceRecord without calling GitHub.
The runner keeps the name github, so its guardrail, drop hook,
condensing, health checks, job id and gates stay as they are.

A cursor that is not a timestamp, such as a buffer position left by the
hosted source, made every polling fetch fail; it now falls back to the
first-run lookback.
In hosted mode the github_pr_activity gate passes without running gh,
since the gateway's pull request watch delivers the changes as records.
nerve doctor reports the hosted GitHub settings, warns about jobs that
still rely on that gate, and no longer asks for gh when nothing needs
it. The setup page skips the gh sign-in check, shows that gh is not
needed, and reports the gateway's streams also when only GitHub is
hosted.
CronService.on_source_delivered drains the source at once, after a run
of it already in progress, and runs it once more for records delivered
meanwhile. Then each enabled job gated on messages for that source, or
for any source, gets one run cron.delivery_debounce_seconds after the
first delivery of a burst. The run waits for a run of the job in
flight, and while it goes on a scheduled or catch-up fire of the job is
skipped, or waits when the job has lock. It never takes the reload lock.

The gateway server points the hosted runtime's delivery listener at the
cron service once it has started, and hands the runtime the database.
Source reloads keep sync.github.mode at the value the service started
with, since the mode is restart-only.
Contract tests for source records and the subscriptions frame; the
settings, the subscriptions builder, the record consumer, the buffer
mapping and the registry; end-to-end runs against the fake gateway,
from a delivered record to the inbox and to the one gated job it runs;
the pull request gate, doctor and setup page in hosted mode; and the
delivery semantics of the cron service.
Config parsing keeps every integer setting as written, so the 600 second
ceiling of cron.delivery_debounce_seconds now applies when the cron
service reads it, and nerve config validate warns about a larger value.
A negative value still reads as 0.
A github_pr_activity record is about one of the agent's own open pull
requests: its reason is always author, and its actors are the reviewers
and commenters. The actor, reason and CI branch rules of sync.github are
written for notifications, so an ordinary allow list of actors or
reasons dropped every such record.

The inbox filter now holds these records to allow_repos and deny_repos
only, as the gateway does before it stores a record. The docs state the
record's shape and the rule.
Nerve reads the events of every hosted provider from one ordered inbox
and stops reading while a hosted channel cannot take events. With
slack.mode: hosted and slack.enabled: false, nothing is read from the
gateway, so hosted GitHub records wait there too and no delivery starts
a job.

nerve doctor now warns about that combination, the docs say so where
they describe pausing hosted Slack and the hosted GitHub source, and a
test pins how records are held back and released. Reading each
provider's events on its own is left for later.
The delivery's own drain logs its start before it runs the source
runner, so a scheduled fire or a manual sync can take the runner in
between. The delivery's run then returned at once, and the jobs gated
on the source could run before the records were in the inbox; with no
debounce that delivery's run was spent.

After its run, the drain now waits until the runner is idle again. Any
run that took the runner started after the records arrived, so once it
ends they are stored.
A run for delivered source records left the pending runs once it
started, and stop() cancelled only those, so a run in progress went on
after the cron service stopped. That matters when startup fails part
way and the cron service is stopped before the hosted streams.

Started runs now have their own set, which stop() cancels and waits for
with the pending ones. Once stop() has begun, a delivery starts nothing:
the drain does not run again, no run is scheduled, and a run about to
start returns. The stop() docstring names everything it stops.
The local check now covers what the gateway also checks on a provider:
a lowercase token within its byte limit, named once per frame. A frame
that fails the check went out without its filter patterns, even when
that could not make it valid. It is now checked again, and if it still
fails nothing is sent and a warning is logged, since the gateway would
close the stream over it.
In hosted mode the gateway applies the published patterns before it
stores a record, so its matcher and the inbox filter must agree, or a
record kept here would be dropped there. The same ten pattern and value
pairs now run against the inbox filter: a star crosses a slash, a
question mark is one character, classes and negated classes work, case
is ignored, and a list value matches on any element.
A reload sends the subscriptions again on every ready stream and waits
for all of them. Each send waited for the stream's send lock with no
deadline, so one stalled stream held up the whole reload. Each stream
now gives up after two seconds, as its drain does, and logs it; the
other streams still send, and the reload reports how many did.

A stream whose publish raised was dropped without a word, and the
publish that negotiation starts surfaced a failure only when the stream
closed. Both are now logged when they happen.
sync.github.mode is restart-only: the source runner and the hosted
streams keep the mode they started with until a restart. The
github_pr_activity gate read the live config instead, so after a reload
that changed the mode it passed without gh while the box still polled,
or ran gh while the gateway already delivered the records. It now reads
the configuration the process started with, and the loaded config only
in a process that did not start the gateway.

The hosted test of the gate made gh raise, which the gate catches and
treats as a reason to run, so it could not fail. The tests now record
the processes the gate starts and check that none ran.
Two links of hosted GitHub had no test that would notice them missing:
the startup step that hands the cron service's delivery hook to the
hosted runtime, and the call in reload_all that publishes the
subscriptions again. The startup test runs the real lifespan with GitHub
hosted and checks what the source consumer calls; the reload test runs
reload_all against open streams and checks the frame and the summary.
A consumer cursor expires after sync.consumer_cursor_ttl_days without a
read that moves it, and an expired cursor starts again at the source's
newest message. A messages gate read the cursors on every check without
renewing them, so a pull request monitor whose source stayed quiet over
a weekend lost the first new record: the gate restarted the cursor at
it and reported nothing, and so would the job's own poll.

Each check now renews the TTL of the live cursors it reads, once less
than half of it is left, without moving them; the forecast still writes
nothing, and a failed renewal leaves the gate's answer as it was. A
cursor that has already expired starts again at the newest message, as
before.
The cron docs said that a github_pr_activity record starts a job gated
on that gate at once. A delivery starts only jobs gated on messages for
the source; a job on the PR gate alone is never started by one, and in
hosted mode it runs in full on every scheduled tick. Delivery runs are
also limited to jobs that are on the schedule.

The sources docs now give the safe way back from hosted to poll: switch
the source off and reload first, so the gateway is told to stop, then
change the mode and restart. They also say that the gateway must know
the subscriptions frame, that blank patterns are not sent, and that a
subscription above the gateway's frame limit goes without patterns.
A setup that may not store credentials offered the GitHub source only
when its token came from the environment. When the channel gateway
polls GitHub no credential is used on this machine at all, so the
switch is now offered there too, with its own reason beside the closed
Gmail and Telegram. In hosted mode the switch writes sync.github.enabled
alone: the events source reads with gh on this machine, which hosted
mode does not set up.

The Sources step also shows what the gateway reports about its streams
when GitHub is hosted, as the Channels step does for a hosted Slack, so
a setup with only GitHub hosted sees it too.
The route that refuses closed sources now describes the GitHub
exception in the same words as the checklist it relies on: the channel
gateway polls GitHub, or its token comes from the environment.
The polling source falls back to the last 24 hours when its cursor is
not a timestamp, as after a switch back from hosted mode, where the
cursor is a buffer position. Eight digits parse as a date, though, so
such a position could become a since date years in the past. A cursor
of digits alone now always counts as no timestamp.
The enrichments of a listing are gathered with their exceptions
returned, and a cancelled one comes back as a CancelledError, which is
not an Exception. notification_record is annotated to take any
exception there, but checked for Exception only, so such a result
failed the whole fetch. It now gives a record built from the
notification alone, as any failed enrichment does.
The module docstring of the hosted sources now says that the gateway
keeps the records until Nerve reads them, also while Nerve is not
running, as the sources docs put it.
The lines added for keeping cursors alive and for the polling cursor
test used timezone.utc, which the linter asks to write as UTC; the
cron service already imports it that way. The lint count of these
changes is back to zero.
The publish that negotiation starts shared the two-second bound of a
reload's republish. Nothing waits for that publish, and nothing sends
the frame again before the next reload or stream, so a stream that
could not send for longer than that, and then recovered, never told the
gateway what the agent subscribes to. The gateway then counts the agent
as subscribed to nothing.

The bound now applies only when the caller asks for it, as the reload's
republish does. The publish after negotiation waits for as long as the
stream is open, and ends with it. Retrying it instead would not do: the
frame is one-way, so a retry cannot tell whether a write that its bound
cut short went out after all, and each retry gives up the frame's place
in the send queue.

Each frame is now built once the stream is free to send it, so a frame
that waited carries the settings current when it leaves, and a reload
that gave up on the stalled stream still reaches the gateway. The
request ID is still taken at the call, so frames leave in call order.
The settings builder turned the gh-based events source on whenever the
GitHub switch was on, in either GitHub mode. The installer's full
settings write, which the unattended init also makes when gh is on the
PATH, therefore switched it on over settings that say hosted, where the
source fails on every run without a gh login. The Sources step, which
leaves that key alone in hosted mode, could then not switch it off.

The setup choices now carry the GitHub mode, and the builder keeps the
events source off while it is hosted. The web steps carry the mode of
the running config. The installer asks nothing about it and takes the
mode that its settings file states, which is the layer of the key that
the installer keeps. The Sources step names the events source in either
mode again, so with hosted GitHub its switch turns the source off both
ways.
pufit added 4 commits October 21, 2026 05:15
The fake gateway's default notification record lacked the description
section of the contract's notification sample. It now has it, so the
defaults give that sample's record byte for byte. The glob vectors that
the gateway shares are also checked through a deny list, which drops a
value exactly when its pattern matches, so a mistake on the deny side
of the dialect fails them too.
Brings in the forecasts of each job's next run and the next-tick API,
the catch-up after a pause of the machine, and the activity reporter,
up to fe51a4c.

The cron service now stops in one sequence. The hosted source
deliveries had a stop flag and a stop step of their own; they now use
the service's flag, which stop() sets before anything else, so a
delivery that arrives or a drain that ends while the service stops
starts nothing, just as the scheduler (paused first), the clock check,
the catch-ups and the schedule listeners do nothing more. stop() puts
the pending and the started delivery runs into the same set as the
clock watcher, the catch-ups and the listener tasks, cancels them
together, waits for them within the same bound, and then shuts the
scheduler down. Stopping a second time does nothing, deliveries
included.

A scheduled fire is still skipped while a catch-up or a delivery run of
its job goes on, and a job with lock still waits for the delivery run.
A delivery run still tells the schedule listeners when it ends. The
forecast of a messages gate takes a cursor as it will be at the run,
which holds with the renewal on every check: the check reads a cursor
before it renews it.
The cron service tracked the runs that hosted source deliveries start
apart from the rest: a count of each job's runs in flight with an event
for when none is left, and a set of the jobs a delivery run was going
for, which skipped a fire of the instance's own. The service already
records every run in progress, with how it was started, and decides from
that whether a new run may start. Delivery runs now count there too, as
runs started for delivered records.

So a catch-up is skipped while a delivery run of its job goes on, as it
is while any run goes on, also for a job with lock, and a catch-up
queued before a delivery run does not run the job again once that run
has happened. A run by hand starts while a delivery run goes on, as it
does in every other case. The delivery drain counts as a run of the
source runner, so the runner's own fire meanwhile is skipped. A
scheduled fire of a job is still skipped while a delivery run goes on,
and still waits for it when the job has lock, now as one of the rules
that decide whether a run may start.
The test of a scheduled fire during a delivery run awaited that fire
without a bound. Had the fire not been skipped, it would have waited on
the run that the test holds, and the test would hang instead of failing.
@alex-clickhouse
alex-clickhouse added this pull request to stack #540 October 21, 2026 14:46
Base automatically changed from pufit/guest-activity-reporter to main October 21, 2026 17:10
@pufit
pufit merged commit 2ec8e3f into main Oct 21, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants