Skip to content

feat(kix): reconcile projects after each webhook - #170

Merged
kixelated merged 14 commits into
mainfrom
quest/m0/kix/reconcile
Oct 21, 2026
Merged

kixelated merged 14 commits into
mainfrom
quest/m0/kix/reconcile

Conversation

@kixelated

@kixelated kixelated commented Oct 21, 2026 •

Copy link
Copy Markdown
Owner

Completes quest/m0/kix/reconcile.md (deleted here, along with its link from quest/m0/kix/README.md). Builds on kixelated/quest#132.

Problem

A webhook left a bare pointer ("Issue #N") in the decision inbox until a maintainer clicked "Refresh from GitHub". Even then, the history pass reads oldest first, so on a long history a new issue could take many refreshes to get its title. A closed issue or PR also stayed "open" until history reread it.

Behavior

  • Background read per webhook. A webhook that names an issue or PR, or that invalidates candidates (PR opened, closed, reopened, or synchronized; quest/ branch created or deleted; default-branch push), asks for a background read of its project once the delivery commits. The read runs in waitUntil with the App's read-only, repository-scoped installation token. It is the same read as a refresh (GitHubService.#read, now shared).
  • Named entities are read by number. Each webhook bumps a per-entity stale count. Every read, including a manual refresh, fetches up to 50 stale entities by number as aliases (s0:issue(number:5)) in the same GraphQL activity query. A new issue therefore arrives with its title after one read, and a closed one stops showing as open. A read clears only the count it saw, so an update that arrives during a read is not lost. If GitHub no longer resolves the number to that entity (it reports NOT_FOUND at that alias; any error scoped to the alias is accepted, so one unreadable entity cannot block every later read), the row is marked missing.
  • Coalescing. A requested flag plus a 60 s lease on github_project_state let one read run per project at a time. A read covers every request made before it started, and requests made during it get one more read, so a burst costs one or two reads. Duplicate delivery IDs never reach it. Checks, statuses, and other pushes name no entity and change no candidate, so they cost no read.
  • Authority. Background reads check repository identity, public visibility, installation gates, and enrollment, but not a maintainer. They only publish observations and grant or cache no authority. Every page and action still rechecks the acting maintainer.
  • Failures. A failed or rate-limited read records its reason in github_project_state.failure and is not retried, unless a webhook asked for another read during it. A read overtaken by another read (including a manual refresh) or by a webhook's revision bump is not a failure: it reads again. The inbox shows the reason next to the "Refresh from GitHub" fallback, as does the activity page. The next webhook or a refresh reads again, and any successful read clears the reason.
  • Paused and disconnected projects are not read.
  • Occupancy fallback (the gap noted in fix(github): keep project availability live on busy repositories #132). The inbox drops a PR whose row still says open once a complete, current occupancy pass no longer lists it, so a missed close webhook corrects itself on the next read.

Migration 0013_reconcile.sql adds the columns (renumbered after kixelated/quest#174 took 0012). It marks existing unread pointers stale.

Decisions made unattended (routine; flag any to revisit)

  1. Coalesce with a D1 lease, not a Durable Object or Queue. It needs no new binding or deploy migration and keeps the state next to the scan cursors it guards.
  2. Skip paused projects. Pausing stops kix's work on a project, and the inbox offers paused projects no new work anyway. A refresh still reads them.
  3. A background read is a full refresh read, including the 3-call snapshot. That is about 9 GitHub calls per read and at most 3 rounds per delivery, well under Workers Free's 50 subrequests. As the quest planned, I left call counts alone until they are measured.
  4. Comments on PRs trigger a read (changed after review). issue_comment on a PR carries the issue ID, not the PR's, so it marks the PR's row stale by number.

Limitations

  • One invocation reads at most three rounds (subrequest budget). A request left after them, which takes more than 150 stale entities at once, such as the migration backfill on a busy repository, waits for the next webhook or a refresh.
  • A maintainer's refresh that races a background read can lose the revision check and show "Project changed during refresh; refresh again". The window is one read (a second or two) right after a webhook. The background read never loses this way: it reads again.
  • After a repository rename, background reads fail ("Repository identity or availability changed") until a maintainer opens the project, which refreshes the stored name.
  • A project that has never been read successfully has no snapshot, so a failed first background read shows no reason, only the refresh fallback.

Validation

  • just check and just test pass in the Nix shell, after merging main (including kixelated/quest#163).
  • New tests in cloudflare/test/projects.test.ts ("webhook reconciliation") deliver signed webhooks through the route:
    • A new issue gets its title from a by-number read, with a read-only token and no maintainer check.
    • A duplicate delivery and CI-only webhooks cause no extra read.
    • A burst during an in-flight read, including repeat updates to the entity being read, gives exactly two reads and leaves every entity read.
    • A failed read is recorded and not retried, a later webhook clears it, and the activity page shows the reason.
    • A closed issue is reread, and a deleted one is marked missing.
    • A quest/ branch push rereads claims.
    • Paused and disconnected projects make no GitHub calls.
  • A new test in cloudflare/test/inbox.test.ts covers the occupancy fallback.
  • After review: a webhook or a manual refresh that overtakes a background read gets another round, and a comment on a PR rereads that PR by number. Each has a regression test that fails without the fix.
  • The seeded local server (kixelated/quest#168) now answers by-number reads, so its issue webhook arrives with its title.
  • Mutation checks: allowing only one round, clearing stale unconditionally, or ignoring the lease each fail the burst test.

Suggested follow-ups

  • Push notifications after the read. kixelated/quest#165 notifies on webhook arrival, also via waitUntil. Whichever lands second could send the "New issue" push after the background read, so it carries the title instead of "Issue #N".
  • Live check of by-number reads of a deleted issue against a real installation, alongside the other live checks in operator setup. Mocks follow GitHub's {type: "NOT_FOUND", path: ["repository", alias]}, and any error at that alias path is accepted.

(Written by Claude Opus 5.5)

🤖 Generated with Claude Code

kixelated and others added 4 commits October 21, 2026 11:44
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A webhook left a bare pointer ("Issue #N") in the inbox until a maintainer
clicked "Refresh from GitHub", and even then the history pass might not
reach a new entity for many pages. Now a webhook that names an issue or PR,
or that invalidates candidates, asks for a background read of its enabled
project with the App's read-only installation token. The read is the same
as a refresh, plus every entity a webhook named since it was last read,
fetched by number in the same GraphQL query, so a new issue arrives with
its title and a closed one stops showing as open.

A lease lets one read run per project at a time; requests made during it get
one more read, so bursts coalesce. A per-entity webhook count lets a read
clear only the updates it saw. Background reads check no maintainer and
publish observations only. A failed read is recorded and shown next to the
refresh fallback, and is not retried. Paused and disconnected projects are
not read.

The inbox also drops a PR whose row says open once a current occupancy pass
no longer lists it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GitHub reports a deleted issue as NOT_FOUND at its alias, but any other
error there would otherwise fail every later read until the entity cleared.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated

Copy link
Copy Markdown
Owner Author

Outcome: the quest is complete. Each webhook that names an issue or PR, or that invalidates candidates, now rereads its enabled project in the background. The read uses the App's read-only token, coalesces through a per-project lease, and fetches every webhook-named entity by number. New issues and PRs reach the inbox with titles, and closed ones drop out, without a manual refresh. A failed read records its reason next to the refresh fallback and is not retried. just check and just test pass after merging main. The four unattended decisions, three limitations, and two follow-ups are in the description.

(Written by Claude Opus 5.5)

@kixelated
kixelated marked this pull request as ready for review October 21, 2026 19:03
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-21T20:25:28.594901Z 03493cd New commits
🔒 Security Review ✅ Completed 2026-10-21T20:28:29.266809Z 03493cd New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fa78a34f72

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread cloudflare/src/github/service.ts Outdated
Comment thread cloudflare/src/github/webhook.ts
*/
async observe(id: number) {
const db = this.#env.DB;
for (let round = 0; round < 3; round++) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Schedule continuation after exhausting observation rounds

Each round reads at most 50 stale entities, but after the third round the loop exits even when the final cleanup has set requested=1. With more than 150 preexisting stale rows after migration, or a sufficiently large webhook burst, no observer remains to process the rest, so those entities stay stale until a later webhook or manual refresh. The final round needs to schedule or otherwise continue the outstanding request.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Leaving this as designed, now documented in GITHUB.md and the observer's doc comment. The cap bounds one waitUntil invocation to about 27 GitHub calls, under Workers Free's 50 subrequests per invocation, and the quest plan rules out retry loops. Continuing past it would need a Queue, Durable Object alarm, or cron sweep, which the PR deliberately avoided (decision 1). The leftover request stays recorded (requested=1), and the next webhook or a refresh continues it; each refresh also reads 50 stale entities. Hitting it takes more than 150 stale entities at once, which in practice means the one-time migration backfill on a busy repository.

(Written by Claude Opus 5.5)

kixelated and others added 7 commits October 21, 2026 12:19
… on PR comments

A webhook that bumps the revision during a background read fails that
read's publish and asks for another read; the observer now runs that round
instead of stopping on the failure. A comment on a PR marks the PR's row
stale by number and asks for a read, so its comment count and change time
stay current.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	cloudflare/src/inbox/page.tsx
The seeded issue webhook now rereads its project, so the fake GitHub answers
by-number reads and the seed test gives each request an execution context.
The seeded issue arrives with its title instead of as a bare pointer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 37855a2fd4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread cloudflare/src/github/service.ts
kixelated and others added 3 commits October 21, 2026 13:10
A background read can lose its revision check to a manual refresh that
listed stale entities before the webhook arrived. That refresh never asks
for a read, so the observer now requeues itself when overtaken instead of
recording a failure, and the overtaken entities are read in the next round.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
0012 is taken by project members on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated

Copy link
Copy Markdown
Owner Author

Merge summary for 03493cd:

  • Review round 1 (fa78a34). Fixed: a webhook that bumps the revision during a background read now gets another round instead of leaving requested=1 with no observer. Fixed: a comment on a PR now marks the PR's row stale by number and asks for a read (this reverses decision 4 in the description). Declined with reasons: continuing past the three-round cap. It stays bounded by the Worker subrequest budget, and the leftover request waits for the next webhook or a refresh. This is documented in GITHUB.md and under Limitations.
  • Review round 2 (37855a2). Fixed: a background read overtaken by a manual refresh that started first now requeues itself (ProjectChanged) instead of recording a failure. Codex found nothing on 682d801.
  • Main merges. Resolved conflicts with kixelated/quest#173 and kixelated/quest#171. Adapted the seeded local server from kixelated/quest#168: its fake GitHub answers by-number reads, and the seed test gives each request an execution context. Renumbered the migration to 0013_reconcile.sql after kixelated/quest#174 took 0012. The only change since the last review is that clean merge and the rename.
  • Each fix has a regression test that fails without it. just check and just test pass in the Nix shell, and CI is green on this head.

(Written by Claude Opus 5.5)

@kixelated
kixelated merged commit 78de3f9 into main Oct 21, 2026
5 checks passed

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 03493cd30a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

)
.bind(lease, id, now, id)
.run();
if (claimed.meta.changes !== 1) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reawaken requests blocked by an orphaned lease

If a Worker dies after acquiring the lease, a webhook arriving before the 60-second expiry sets requested=1 and starts observe(), but this return ends the new task because the old lease is still active. The dead task cannot clear the lease, and nothing wakes the queued request when it later expires, so the webhook remains unreconciled until a third webhook or a manual refresh; arrange a continuation at lease expiry or use a mechanism that guarantees another consumer.

Useful? React with 👍 / 👎.

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.

1 participant