Skip to content

Contributor mode: keep notifications when delivery is refused or no host can receive them (builds on #6 and #16) - #22

Open
theaspirational wants to merge 30 commits into
anima-research:mainfrom
theaspirational:contributor-delivery-fixes
Open

theaspirational wants to merge 30 commits into
anima-research:mainfrom
theaspirational:contributor-delivery-fixes

Conversation

@theaspirational

Copy link
Copy Markdown
Contributor

Fixes to contributor mode (#6) found while running it together with #16 on current main.

This PR builds on #6 and #16. It contains both of them merged onto main (4a8964b8), with the #6 conflicts against current main resolved (27e4331), plus 13 commits on top. If #6 and #16 land first, only the top commits remain. Please review only 27e4331..HEAD (16 files, +737/-70) and the merge resolution in 27e4331. If you would rather take these as fixes inside #6, say so and I will retarget.

What changes (contributor mode)

  • Delivery is not lost when no host can receive it. A notification pass pauses, with no API or state changes, when no connected MCPL host has a granted event egress. An eligible host resumes polling; cursors survive the pause.
  • Refused handoffs are retried without freezing the listing. A refused thread event keeps its own cursor; the global listing watermark and conditional header still move. After five failed passes a persisted terminal record stops the cycle and its drop notice from repeating, including across a restart. A refused checks.completed keeps its existing head/TTL schedule.
  • Either granted egress counts as delivery. If the channel message is accepted and the push is declined (or the reverse), the poller no longer re-sends the accepted one; the decline is warned about and treated as final for that egress.
  • Truncated notification windows stay available for retry instead of being skipped.
  • Review pagination walks descending pages through empty pages, with an explicit read cap, and keeps first-page reviews after an empty tail page.
  • Docs: README and changelog.d/contributor-delivery.fixed.md describe the delivery, retry and truncation contracts, including the shared-token rate-limit cost of a window that stays saturated.
  • Tests: synthetic tests for each of the above; the host lifecycle e2e check now starts its fixtures through the host's Node path.

Tests

On this exact tree, Node 22.12.0 and 22.23.3: npm ci, typecheck, build, npm test: 771 tests, 751 pass, 0 fail, 20 skipped. Also 771/771 with @animalabs/agent-framework 0.30.0 installed. All tests are synthetic: no GitHub API calls and no credentials.

Co-authored with GPT-6-Astra; each commit carries a Co-Authored-By trailer.

The-Glassy and others added 30 commits October 17, 2026 09:18
Opt-in second auth + ingress mode alongside the GitHub App mode. When
GITHUB_TOKEN_FILE is set (and the App vars are absent) the adapter
authenticates every REST call with that token directly instead of minting
installation tokens; GITHUB_REPOS may then span several owners since writes
are still checked against the allow-list. Both auth configs at once is
refused as ambiguous, and GITHUB_WEBHOOK_PORT is refused in this mode.

Ingress is a new notifications poller (GET /notifications?participating=true,
If-Modified-Since/Last-Modified, X-Poll-Interval honoured) that re-reads each
notified issue/PR for new comments, review comments, submitted reviews and
state changes, normalizing them through the existing normalize.ts pipeline so
they produce the same tags/metadata/channels as webhook or repo-poll events.

Tests cover config validation for both modes, the token auth header, and the
poller's 304 path, event shaping, cross-restart dedup and interval handling.
…s place and fails

Contributor mode keeps its design, a user token with GET /notifications
as its only ingress. This commit changes how the poller reads, and
closes a way the user token let search_issues reach past GITHUB_REPOS.

Listing. Each pass lists all=true&participating=true&since=W, every
page, with If-Modified-Since. W is the listing's Last-Modified less a
second; without that header, it is the newest stamp the listing showed,
less a second. Read and unread threads are listed alike, so the poller
marks nothing read and leaves the user's inbox as it was. The poller
drops notifications from repositories outside GITHUB_REPOS before any
read, since the token reaches every repository its user can read.

Reads. The poller reads each listed thread since its own cursor: its
comments, and for a pull request its review comments and reviews, every
page. The thread's row comes from the issues endpoint and goes through
normalizePolledIssue, as App mode's repository poll does, so a merge
arrives as pull.closed with whoever merged it as the actor. GitHub can
stamp a notification 20 s or more after the activity it reports, so a
thread read for the first time starts five minutes before GitHub's
previous answer to a listing.

Failures. W depends only on what each listing shows, not on any thread's
read, so one failing thread holds no other thread back. A thread whose
read or delivery fails waits on a retry list that keeps its cursor, and
is read first on later passes, for up to five passes in a row. Then it's
dropped, with one thread.read_failed event on its channel that names the
cause and carries chat:mention, so a host wakes the agent for it. A
thread that is gone or out of the token's reach is dropped at once.
GitHub's rate limit counts no pass against a thread and ends the pass's
reads. A thread with more comments and reviews to read at once than one
read takes is read from its latest update on, and its event says its
backlog was skipped.

Search. search_issues drops user:, org: and owner: qualifiers as it
already dropped repo:, including one right after a quote or a bracket,
since GitHub widens a search to each of them. It leaves out any result
on the page from another repository, saying how many. This applies in
App mode too.

Start checks. At start the server checks GITHUB_BOT_LOGIN against GET
/user and each GITHUB_REPOS entry against GitHub's spelling of it, and a
mismatch stops the start with the value to set. readConfig refuses
GITHUB_APP_INSTALLATION_ID beside GITHUB_TOKEN_FILE, and drops a token
file's leading whitespace or byte-order mark.

Docs and tests. The README names the classic token the notifications
endpoints require, its narrowest scopes, and an account the agent alone
posts from. The two tests that expect a world-readable secret file to be
refused now chmod the file, since a 077 umask narrowed the mode they
asked for and made them fail.
…'s edge, tag polled merges, drop the head read and the second recipe

This commit removes two parts, the pull request head read and the
separate token recipe, and fixes what a second internal review of the
whole change found.

Cuts. getPolledThread reads only the issues endpoint. The head sha it
also read cost a request on every read of an open pull request. It
served pull.synchronize, and the sha: line of a polled pull.opened.
GitHub lists a pull request's thread when someone pushes to it, but a
polled event's actor is the pull request's author, so a maintainer's
push to the agent's own branch would have read as the agent's own
anyway. So this mode delivers nothing for a push, and a polled opening
comes without a sha. The agent learns of new commits from what comes
with them, or by reading the pull request. A review takes its sha from
its own commit_id. A reopened pull request now arrives as nothing, as
through a webhook. examples/recipe-contributor-token.json is gone. Its
wake policies were recipe-contributor.json's, field for field, and the
README points at that recipe for both modes.

Reviews. listReviewsSince reads page 1, whose Link header names the last
page, then reads back from the last page until a page starts before
since. The list takes no since and lists reviews in the order they were
submitted, so reading every page from the first counted a pull request's
whole review history against the 2,000-row cap: past 2,000 reviews a
pull request could never be read again, and every read paid for all of
them. Now the cap counts only pages that hold new reviews, and a Link
that names a next page but no last fails the read rather than being
taken for one page.

Cursors. A thread's cursor keeps the keys of the rows its next read takes
again, from a second before its since, and that read drops them itself.
Before, it relied on the dedup ring, which keeps 2,000 keys, so a thread
that sat quiet while other threads filled the ring delivered its last
comment a second time when it moved.

Merges. A polled merge is tagged github:pr=merged beside
github:pr=closed, as a webhook delivery's is, from the issues row's
pull_request.merged_at. App mode's repository poll gets this too.

Smaller fixes. The read-failed event's actor is github-mcpl[server], a
login no GitHub account can have, so the event is never from-self, and
its text no longer points the agent at the read tools, which can fail
the same way. The poll interval is capped at setTimeout's limit, so a
very large GITHUB_POLL_INTERVAL_MS doesn't poll every millisecond.

Docs. The README says what this mode can't see (pushes, review requests,
check runs, an edit to an open pull request, an edit on an older thread
read for the first time), who a polled edit is attributed to, that the
backlog cap is per list, and what the event pipeline's first read of a
thread costs. The contributor recipe says review requests reach it with
the App only.

Tests cover the review read, the cursor's edge, the merge tag, the
event's actor and text, and the interval cap; each fails without its
change.
…'s own pull requests, default GITHUB_BOT_LOGIN to the token's login

- A notification whose reason is review_requested arrives as pull.review_requested, keyed by the
  notification's id and updated_at; one refused waits on its thread's retry entry, and one beside a
  skipped backlog still arrives. Both example recipes let a review request on a bot's pull request
  past github-other-bots.
- Each pass whose reads met no rate limit searches the token user's open pull requests in
  GITHUB_REPOS and looks up the checks on each head, up to 20 a pass in turns, emitting App mode's
  checks.completed once per result. A head read stands while the pull request's updated_at does,
  for ten minutes at most; a rate limit ends the lookups until the next pass.
- GITHUB_BOT_LOGIN is optional in contributor mode: unset, startup takes it from GET /user, and
  stops when GitHub doesn't answer. A set value is still checked against the token.
- README: what a pass costs and sees now, the env table, and that an APPROVE through github.review
  is the user's own approval and counts toward branch protection.
…moves, and fix the reviews cap at a later boundary

Checks: a head whose finished result was delivered is looked up again only
when its pull request's updated_at moves or its head read is ten minutes
old, so a re-run on the same head still arrives. With 20 open pull requests
whose checks have finished, that's, by our reading, about 240 core requests
an hour where it was about 1,320, besides the search each pass. The module header no longer
says App mode does the same per pass, and the README says what a 304 pass
costs.

Reviews: a read past the cap now reads that page and fails only if it
holds a new review, as page 1 already did, so 2,000 new reviews above an
old page 2 are one read.

Also: a review request refused beside a backlog skipped on every pass
stops at the retry limit; three adapter errors name their operation once;
the README says a review request comes once per listed update with that
reason, and covers the 100 most recently updated pull requests; and tests
for a review request across a rate limit, malformed stored review
requests and a failed search.
… or it has none

A failure is reported while other runs still go, and the last commit left
such a head until it moved or ten minutes passed, so a later failure on it
waited. ChecksSummary gains an optional unfinished flag, set when a run is
going or wasn't read, and the poller leaves a head only once its result is
finished. A head with no check runs two minutes after its head was read is
left the same way, so a repository without CI no longer costs a lookup each
pass; the two minutes cover a push whose runs aren't created yet.

The module header and README time the ten minutes from the head read, the
README says the search runs on passes whose thread reads met no rate limit,
and the changelog and events doc name the 100 most recently updated pull
requests. Tests for an unfinished failure, a head with no runs, the turn
moving past heads left alone, a known result on a refreshed head, a mixed
page past the reviews cap, and the operation named once in two more errors.
…he flag that decides it

Text and tests only. The module header, README and events doc name both
ways a head is left (a result with every run finished, or no runs two
minutes after its head read) and that a failure beside running runs is
looked up each pass; NO_CHECKS_SETTLE_MS says it is a choice, not a
measured bound; isCompletedSummary's doc says decided rather than
finished. Tests for summarizeChecks' unfinished flag, a repeated
unfinished failure, a head whose runs still go past two minutes, and the
failed search being made.
Three tests drive a server over a stream pair written as raw lines:
- a request whose JSON string holds U+2028 or U+2029 gets its answer;
- a malformed line is logged, and the next request is answered;
- a stream error ends serve() and is logged.

On main, with @animalabs/mcpl-core 0.3.0 and no 'error' listener on the
connection, each of the three ends the test process with an unheard
'error'.
0.4.0 reads a line only at \n, so U+2028 and U+2029 inside a JSON
string no longer split a frame on Node 24. It also closes a connection
once when one of its streams fails, and reports that as 'error'. Its
acceptTcp no longer leaves listeners on the server, so the server's own
'error' between accepts is the caller's to hear.

- package.json: @animalabs/mcpl-core ^0.4.0, and engines.node >=22.12.0,
  0.4.0's own floor. The lockfile takes 0.4.0.
- serve() listens for its connection's 'error' and logs it. A malformed
  line leaves the connection open; a stream error closes it, so serve()
  returns: over TCP the next host can connect, and under --stdio the
  process shuts down as it does when stdin ends.
- The TCP server gets an 'error' listener once it is listening, which
  logs. A failed accept while one is pending still rejects that accept.
- scripts/e2e/fake-host.mjs recognises a closed connection by
  ConnectionClosedError's name: two of 0.4.0's three texts for it no
  longer say "connection closed". The comment above it names the class.
- README.md and deploy/github-mcpl.env.example give the Node floor as
  22.12.0.
- Two changelog fragments: the Node floor (breaking) and the fix.
On Node 22, readline doesn't end a line at U+2028 or U+2029, so the test
for those frames passes there with @animalabs/mcpl-core 0.3.0 too. CI now
runs on Node 22 and 24, so it can fail where the split happens. The test
now asserts each of the two requests is answered with a result, as the
malformed-line test does, rather than with any answer.
main now carries the faster test suite (anima-research#18), which gives CI's job a
10-minute timeout where this branch adds the Node 22 and 24 matrix. The
job keeps both.
main now runs CI the testing setup's way (anima-research#19, anima-research#20): the whole suite once
at a pinned Node, and an install and start check on engines' floor, Node 24
and the newest Node release. With engines at >=22.12.0 here, the
resolution keeps that shape at this branch's floor:

- The suite job runs at Node 22.12.0, engines' floor, in place of this
  branch's Node 22 and 24 matrix of whole suites.
- The install and start check runs on Node 24 and on the newest release.
  main's 22.0.0 leg checked the old floor; the floor is now 22.12.0, where
  the suite job runs.
- README's development line keeps this branch's "Node 22.12 or newer",
  which covers npm test as well as the server.
Co-Authored-By: GPT-6-Astra <noreply@openai.com>
…nd write safety

Keep repository poll cursors alongside notification watermarks. Preserve possiblyDone and answered error fields, adapt notification error construction, and retain the CI-required wake matcher checks.

Co-Authored-By: GPT-6-Astra <noreply@openai.com>
…ations

Co-Authored-By: GPT-6-Astra <noreply@openai.com>
Co-Authored-By: GPT-6-Astra <noreply@openai.com>
Co-Authored-By: GPT-6-Astra <noreply@openai.com>
Co-Authored-By: GPT-6-Astra <noreply@openai.com>
Co-Authored-By: GPT-6-Astra <noreply@openai.com>
Co-Authored-By: GPT-6-Astra <noreply@openai.com>
Co-Authored-By: GPT-6-Astra <noreply@openai.com>
Co-Authored-By: GPT-6-Astra <noreply@openai.com>
Co-Authored-By: GPT-6-Astra <noreply@openai.com>
Co-Authored-By: GPT-6-Astra <noreply@openai.com>
Co-Authored-By: GPT-6-Astra <noreply@openai.com>
Co-Authored-By: GPT-6-Astra <noreply@openai.com>
Co-Authored-By: GPT-6-Astra <noreply@openai.com>

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review

Recommendation: needs a maintainer's decision. The change belongs, but part of it needs a maintainer's call.

Reviewed commit 2d39f8b with claude-opus-5-5, by reading the code. It did not run the code or look at the checks.

Does this change belong in the project?

It fits, with concerns. The top commits are reliability fixes and docs for contributor mode, and they work through the server's single event pipeline, so they belong if #6 is taken. The concern is that this PR also carries all of #6 and #16, two separate and still-unmerged proposals, and those need their own decisions.

5 reasons with sources
  • The PR bundles all of #6 (contributor mode) and #16 (mcpl-core 0.4, Node floor, CI matrix) along with its own fixes. The diff adds src/notifications-poller.ts in full, plus changelog.d/contributor-mode.added.md, mcpl-core-0.4.breaking.md and mcpl-core-0.4.fixed.md. It also changes package.json and package-lock.json, and drops the 22.0.0 install check from ci.yml. changelog.d/README.md asks for one fragment per change, and this PR adds four from three different changes. Maintainers should decide on #6 and #16 separately and merge this only as the top commits (27e4331..HEAD). (README.md (base); changelog.d/README.md)
  • Whether this PR belongs depends on #6, because the base project has no contributor mode. The base README says one server serves one repository or several of one owner, with one process per owner, and that GITHUB_REPOS entries must share one owner (one App installation, one token). Contributor mode changes that with a user token and repositories across owners. That scope decision belongs to #6. These fixes only matter if it is accepted. (README.md (base), lines 3-7 and 242)
  • The fixes route contributor events through the existing shared event pipeline and do not add a second path. The poller hands events to the same onEvent/handleEvent that the webhook and the App-mode poller use. The new canReceiveEvents only reuses the existing eventEgressAllowed check. Per the separate code review, it treats a grant of channels.incoming without channels.register as ready, so the readiness check does not fully work yet. (src/server.ts, event pipeline section)
  • Not losing events when no host can receive them fits the project's 'wake, not poll-and-hope' purpose. The README sells the connector on reliable push wakes. Pausing when there is no granted egress, retrying refused handoffs, and counting either egress as delivery all follow from the base README's rule that github.events needs only one of pushEvents or the channel paths (lines 323-328). (README.md (base), lines 9-12)
  • The docs and changelog fragment follow the repository's conventions. contributor-delivery.fixed.md is a flat .fixed.md file made of bullets, as the fragment rules require. The behavior is documented in the README alongside the existing mode sections. (changelog.d/README.md)

Findings

6 findings of medium or high severity, see the inline comments:

4 more findings, less severe
  • Low severity, documentation, src/server.ts:1282-1295. The pipeline's JSDoc now sits on canReceiveEvents, leaving two stacked doc comments. Inserting canReceiveEvents between the existing 'Every ingress path hands its events here…' doc block and onEvent attaches that block to nothing, followed directly by the new block for canReceiveEvents. Tooling and readers will link the pipeline description to the wrong member, and onEvent, now returning a boolean receipt, has no doc saying what true and false mean. Move the old block back onto onEvent and document the receipt there.
  • Low severity, maintainability, .github/workflows/ci.yml:14-16. The install-and-start check no longer runs on the engines floor. Before, the matrix ran the install/start check on the engines floor (22.0.0). It now runs only on '24' and 'latest', even though engines was raised to >=22.12.0. The full suite runs on 22.12.0, but the packaged install-and-start path is no longer exercised on the minimum supported Node. Adding '22.12.0' to the matrix keeps the floor covered. This CI change is also not mentioned in the PR's stated purpose.
  • Low severity, test coverage, test/notifications-poller.test.ts:1382-1441. No test for a grant that passes canReceiveEvents but cannot deliver (incoming without register). The receipt tests grant ['channels.register', 'channels.incoming'] or ['pushEvents'] together, and test 'ungranted' as nothing at all. No case grants channels.incoming alone, so the gap between canReceiveEvents and the registry's canRegister is not covered. Add a case that asserts the poller pauses there, or at least does not drop threads.
  • Low severity, test coverage, test/notifications-poller.test.ts:1061-1084. Tests lock in that terminal retry records stay after success, with no test that they are ever pruned. The test asserts a dropAnnounced entry is still there (notificationRetries()[0].dropAnnounced === true) after the thread succeeded. No test covers terminal entries piling up across many threads, or their eventual removal, so unbounded growth of notifications.retry would go unnoticed.

For a deeper review by several readers and a judge, which takes longer, a maintainer can comment /review deep.

Comment thread src/server.ts
Comment on lines +1289 to +1293
readonly canReceiveEvents = (): boolean => {
const conn = this.conn;
return !!conn && !conn.isClosed &&
(this.eventEgressAllowed(conn, 'push') || this.eventEgressAllowed(conn, 'incoming'));
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium severity. canReceiveEvents reports ready for a host that granted channels.incoming without channels.register, though nothing can be delivered.

canReceiveEvents returns true when eventEgressAllowed(conn, 'incoming') holds, and that check only tests channels.incoming. Channel delivery also needs registration: ChannelRegistry.register returns undefined when canRegister() (grant.has('channels.register'), server.ts:668) is false, and deliver then answers channel-not-registered. Take a host that grants channels.incoming but neither channels.register nor push. The poller does not pause, every thread handoff returns false, and each thread uses up its five attempts. Each is then dropped with a notice the host also refuses. This is the case the PR says it handles: delivery should pause while no host can receive. The predicate should require registration for the incoming path, or ask the registry the same question it asks itself.

Comment on lines +601 to +635
if (outcome.failure === null) {
if (waiting?.dropAnnounced) {
// A review request can finish after its backlog notice. Keep the
// notice's terminal record so re-listing that update cannot repeat it.
retries.set(channel, { ...retryEntry(thread, attemptsSoFar,
this.state.polledHead(`notif:${channel}`), undefined, waiting), stopped: true });
} else retries.delete(channel);
} else {
result.failed += 1;
const place = this.state.polledHead(`notif:${channel}`);
if (outcome.failure.kind === 'rate-limit') {
rateLimited = 1;
// To the back of the list, so the thread that met the limit can't keep the rest waiting.
retries.delete(channel);
retries.set(channel, retryEntry(thread, attemptsSoFar, place, outcome.request, waiting));
} else {
const attempts = attemptsSoFar + 1;
const retry = retryEntry(thread, attempts, place, outcome.request, waiting);
if (outcome.failure.kind !== 'failed' || attempts >= NOTIFICATION_RETRY_LIMIT) {
result.dropped += 1;
// A skipped backlog can still owe a review-request handoff, but
// it shares the same five-pass budget and one notice.
retry.stopped = !(outcome.failure.kind === 'backlog' && outcome.request && attempts < NOTIFICATION_RETRY_LIMIT);
if (!retry.dropAnnounced) {
// Persist before the notice: a crash or refusal must not make
// this same notification produce another drop announcement.
retry.dropAnnounced = true;
retries.set(channel, retry);
this.state.setNotificationRetries([...retries.values()]);
await this.state.flush();
result.delivered.push(...(await this.announceDrop(thread, outcome.failure, attempts, receivedAt)));
}
}
retries.set(channel, retry);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium severity. Terminal (stopped) retry entries are never removed, so the persisted retry list grows without bound.

Terminal records are kept: stopped: true entries for lasting failures, exhausted retries, skipped backlogs, and threads that succeeded after a dropAnnounced notice (lines 602-607). Such a record is removed only when a strictly newer notification for the same thread is listed and its read then succeeds. A thread that is deleted, goes private or never moves again keeps its entry forever. notificationRetries() copies the whole list, and setNotificationRetries plus flush() serialize the full snapshot after every thread of every pass (lines 637-638). A long-running contributor process therefore keeps writing a growing state file, and nothing ever trims it. Old terminal records need an age or count bound, for example dropping them once the watermark has passed their updatedAt by some margin.

Comment thread src/server.ts
Comment on lines 1316 to 1319
} catch (err) {
this.logger.error('event pipeline failed', { event: event.id, error: (err as Error).message });
return;
return false;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium severity. A pipeline exception now becomes a refused receipt, so one event that always fails blocks its whole thread forever.

handleEvent used to return void when tagContext, tagsFor or renderEvent threw. It now returns false, and the notifications poller treats false as non-delivery. Take an event whose rendering or tagging fails every time (for example malformed content). Its thread's cursor never moves past it. The thread is retried for five passes, stopped, and then reopened by each newer notification from the same held cursor (attempts reset to 0). The same event fails again each time, so every later comment or review on that thread is held behind it indefinitely. Before this change the event was logged and skipped. Transient GitHub failures inside tagContext (including rate limits) are also counted as ordinary 'failed' attempts. Errors that will repeat should be told apart from refusals, or the event should be skipped once retries are exhausted.

Comment on lines +506 to +510
async pollOnce(): Promise<NotificationsPollResult> {
if (!this.ready()) return {
notModified: false, threads: 0, outOfScope: 0, delivered: [], duplicates: 0,
failed: 0, dropped: 0, truncated: false, checkLookups: 0,
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium severity. Readiness is checked only at the start of a pass, so a host that leaves mid-pass uses up retry budget.

pollOnce calls this.ready() once, before the listing. Suppose the host disconnects or revokes egress while the pass is reading threads. Each remaining thread's handoff then returns false (handleEvent sees no conn or no grant). The thread is counted as failed, its attempts go up, and with enough such passes (for example a host that reconnects often) it is dropped with a drop notice that nobody receives. The PR says passes pause without state changes while no host can receive. The loop over toRead (and pollOwnChecks) should check canPoll() again and stop the pass rather than count failures.

Comment on lines +722 to +742
const deliver = async (candidates: Candidate[]): Promise<string | null> => {
let refusal: string | null = null;
for (const { event } of candidates) {
const key = ringKey(event);
if (alreadySeen.has(key) || cursor.edge.includes(key)) {
duplicates += 1;
continue;
}
try {
await this.deliver(event);
} catch (err) {
refusal = err instanceof Error ? err.message : String(err);
this.logger.warn('notifications poll: event delivery failed; its key stays unrecorded for the next pass', { key, error: refusal });
continue;
}
this.state.seenPollKey(key);
alreadySeen.add(key);
delivered.push(event);
}
return refusal;
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium severity. After a refused row, later rows in the same thread are still delivered, so the refused one comes later and out of order.

In pollThread's deliver closure, a refused or thrown handoff only records refusal and does continue. Later candidates in the same sorted batch are still handed to the host and recorded in the dedup ring. The refused row is held by the cursor and delivered on a later pass, after newer comments or reviews on the same thread. Now that hosts can decline with an explicit false receipt, this happens on ordinary refusals and not only on exceptions. The agent then sees replies before the comments they answer. Stopping the batch at the first refusal would keep thread order.

Comment thread src/github-adapter.ts
Comment on lines +1760 to +1765
if (batch.length === 0) {
// A shrinking or sparse tail says nothing about earlier pages.
// Keep walking, but an all-empty walk must obey the same read budget.
if (last - page >= THREAD_MAX_PAGES) throw new ReadCapError('listReviewsSince');
page -= 1;
continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium severity. A walk over empty review pages throws ReadCapError, so the agent is told about a 2,000-review backlog that does not exist.

When the Link header names a last page more than THREAD_MAX_PAGES (20) above the walk's position and the tail pages are empty, listReviewsSince throws ReadCapError. The poller turns that into BacklogTooLarge: it moves the thread's cursor to its current update, skipping any unread rows, and posts a thread.read_failed notice. The notice reads 'it had more than 2,000 comments or reviews to read at once', yet those pages held no reviews at all. The read budget is reasonable, but this failure should be reported as its own kind (an empty or inconsistent listing), not as a backlog skip that misinforms the agent.

theaspirational added a commit to theaspirational/github-mcpl that referenced this pull request Oct 29, 2026
Retain both ThreadKind from contributor delivery (anima-research#6/anima-research#22) and SubmittedReviewState from review reads in the sole import conflict.

Co-Authored-By: GPT-6-Astra <noreply@openai.com>
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