forge: durable outbox and webhook reconciliation (SPEC §8.3) #14

Manually merged
krisbuild merged 6 commits from lu/forge-outbox into main 2026-09-27 22:37:40 +02:00
Owner

What

docs/live-upgrades.md §4.3, "Forge outbox" and "Webhook reconciliation".

Forge outbox. Every forge write — commit statuses, merge-queue comments, mark-merged — is a row in forge_outbox, written in the same transaction as the state change that causes it (graph created → pending; graph terminal → aggregate + final per-task statuses; each merge-queue transition → krisbuild/queue status, comment, mark-merged). One worker drains it with exponential backoff persisted in the row (2 s doubling to 5 min); dropped on a 4xx other than 408/429 or after a day of failures; everything due is retried on restart. Statuses coalesce per (repo, sha, context) — the newest unsent one replaces the older. Comments are deleted as soon as the forge accepts them (a crash in between may post one twice — accepted, documented); writes to one PR stay ordered. The in-memory reporter and the driver's direct comment/merge calls are gone (ForgeWriter, single attempt, forgejo/writer.rs).

Reconciliation. Push and PR webhooks record their head per (repo, ref) in forge_refs. On boot and every forge_reconcile_secs (default 300, 0 disables), each repo's branches and open PRs are listed; a differing head goes through the webhook's own handlers (http::on_push / on_pull_request, refactored to be shared) with trigger.source = "reconcile" — all filters apply. A recorded PR no longer open is fed through as closed. A repo's first run only seeds. For merge-queue repos, new comments are fed to the same command handler, which claims each comment id in forge_comments before applying it, so a command seen by webhook and by listing is applied once.

Schema: additive only (forge_outbox, forge_refs, forge_reconcile, forge_comments); older binaries still run on it.

Why

A CP stop at the wrong moment left a check pending forever, and webhooks delivered during a restart were lost (Forgejo doesn't redeliver).

Docs

SPEC §8.3 (new), §8 adapter surface, §9 schema; forgejo-setup.md: forge_reconcile_secs, "Restarts and missed deliveries".

Tests

  • Unit: status coalescing, per-PR ordering, backoff/give-up, timestamps, comment claims, forge_refs, migration of an older DB.
  • tests/forge_outbox.rs: graph created and finished while the forge returns 503, server stopped; a new server on the same DB posts only success.
  • tests/reconcile.rs: missed push built once (reconcile); already-seen heads and kb-queue/* skipped; missed PR update built, missed close cancels; missed r+ applied, a late webhook for it is "already handled", edited old comments ignored.
  • tests/forge_api.rs: writer calls and listings. Fake forge gained listings and a write-failure switch.

🤖 Generated with Claude Code

## What docs/live-upgrades.md §4.3, "Forge outbox" and "Webhook reconciliation". **Forge outbox.** Every forge write — commit statuses, merge-queue comments, mark-merged — is a row in `forge_outbox`, written in the same transaction as the state change that causes it (graph created → `pending`; graph terminal → aggregate + final per-task statuses; each merge-queue transition → `krisbuild/queue` status, comment, mark-merged). One worker drains it with exponential backoff persisted in the row (2 s doubling to 5 min); dropped on a 4xx other than 408/429 or after a day of failures; everything due is retried on restart. Statuses coalesce per (repo, sha, context) — the newest unsent one replaces the older. Comments are deleted as soon as the forge accepts them (a crash in between may post one twice — accepted, documented); writes to one PR stay ordered. The in-memory reporter and the driver's direct comment/merge calls are gone (`ForgeWriter`, single attempt, `forgejo/writer.rs`). **Reconciliation.** Push and PR webhooks record their head per (repo, ref) in `forge_refs`. On boot and every `forge_reconcile_secs` (default 300, `0` disables), each repo's branches and open PRs are listed; a differing head goes through the webhook's own handlers (`http::on_push` / `on_pull_request`, refactored to be shared) with `trigger.source = "reconcile"` — all filters apply. A recorded PR no longer open is fed through as `closed`. A repo's first run only seeds. For merge-queue repos, new comments are fed to the same command handler, which claims each comment id in `forge_comments` before applying it, so a command seen by webhook and by listing is applied once. Schema: additive only (`forge_outbox`, `forge_refs`, `forge_reconcile`, `forge_comments`); older binaries still run on it. ## Why A CP stop at the wrong moment left a check `pending` forever, and webhooks delivered during a restart were lost (Forgejo doesn't redeliver). ## Docs SPEC §8.3 (new), §8 adapter surface, §9 schema; forgejo-setup.md: `forge_reconcile_secs`, "Restarts and missed deliveries". ## Tests - Unit: status coalescing, per-PR ordering, backoff/give-up, timestamps, comment claims, forge_refs, migration of an older DB. - `tests/forge_outbox.rs`: graph created and finished while the forge returns 503, server stopped; a new server on the same DB posts only `success`. - `tests/reconcile.rs`: missed push built once (`reconcile`); already-seen heads and `kb-queue/*` skipped; missed PR update built, missed close cancels; missed `r+` applied, a late webhook for it is "already handled", edited old comments ignored. - `tests/forge_api.rs`: writer calls and listings. Fake forge gained listings and a write-failure switch. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
forge: durable outbox and webhook reconciliation (SPEC §8.3)
Some checks reported errors
krisbuild/kris/krisbuild/nix/workspace-deps cached
krisbuild/kris/krisbuild/nix/hello cached
krisbuild/kris/krisbuild/nix/world cached
krisbuild/kris/krisbuild/nix/clippy failed on ares (exit-code)
krisbuild/kris/krisbuild/nix/build succeeded
krisbuild/kris/krisbuild/nix/kb-check failed on nuxbox (exit-code)
krisbuild/kris/krisbuild/nix/test superseded
krisbuild/kris/krisbuild krisbuild kris/krisbuild: superseded by b3777b3951ae096fa66c7917d57843b5913ee540
0609f7943d
Every forge write now goes through a `forge_outbox` table, written in the
same transaction as the state change that produces it: the `pending` at
graph creation (webhook and rerun), the aggregate and final per-task
statuses when a graph finishes, and each merge-queue transition's
`krisbuild/queue` status, comment and mark-merged. One worker drains it
with persisted exponential backoff (2 s doubling to 5 min, dropped after a
day or on a non-transient 4xx); a restart makes every row due at once.
Statuses coalesce on (repo, sha, context) so only the newest state is
sent; comments are deleted right after the forge accepts them (a crash in
between may post one twice) and writes to one PR keep their order. The
in-memory reporter and the driver's direct comment/merge calls are gone.
Per-task statuses of running graphs stay derived by the scheduler sweep,
and graph completion now enqueues the final ones itself, replacing the
post-completion re-sweep.

Reconciliation recovers webhooks sent while the control plane was down:
heads reported by push/PR webhooks are recorded in `forge_refs`; at
startup and every `forge_reconcile_secs` (default 300, 0 = off) each
repo's branches and open PRs are listed and a head that differs is fed
through the webhook's own handlers with `trigger.source = "reconcile"`,
so repo policy, ref filters, one-sha-one-run, kb-queue/* and the fork
policy apply unchanged. A recorded PR no longer open is fed through as
`closed`. The first run per repo only seeds. For merge-queue repos, PR
comments created since the last run are handed to the command handler,
which claims each comment id in `forge_comments` before applying it, so
a command seen by webhook and by listing is applied once.

Schema changes are additive (four new tables, no version bump).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Merge origin/main into lu/forge-outbox; address review of #14
Some checks failed
krisbuild/kris/krisbuild/nix/hello cached
krisbuild/kris/krisbuild/nix/workspace-deps cached
krisbuild/kris/krisbuild/nix/world cached
krisbuild/kris/krisbuild/nix/clippy failed on ares (exit-code)
krisbuild/kris/krisbuild/nix/build succeeded
krisbuild/kris/krisbuild/nix/kb-check succeeded
krisbuild/kris/krisbuild/nix/test succeeded
krisbuild/kris/krisbuild krisbuild kris/krisbuild: one or more tasks failed
krisbuild/queue the pull-request head changed
b3777b3951
Conflict resolution: the merge queue's new failure/rerun/unqueued-base
paths enqueue their comments and statuses through the outbox; reruns
enqueue their `pending` in the rerun transaction; the in-memory
SeenComments guard is replaced by the durable `forge_comments` claim.

Review fixes:
- A reconciled `r+` approves the current PR head only if that head was
  recorded (forge_refs keeps when each sha was first seen) no later than
  the comment was written; otherwise it approves nothing and asks for
  `r+` again (SPEC §8.2 sha-bound approval).
- Recorded heads are read before listing; each reconcile feed or forget
  runs under a per-repo lock that webhook push/PR handling also holds,
  and only while the record still equals that snapshot. Seeding never
  overrides a webhook-recorded head.
- Comments are listed from max(cursor - overlap, seeded_at); a stale
  undelivered `pending` (>10 min) is dropped at startup; rollback and
  roll-forward documented in forgejo-setup.md.
- Listings page until an empty page; a truncated listing skips vanish and
  close detection and does not advance the comment cursor.
- An unreadable `.kb/ci.toml` is `OpenOutcome::Unavailable`: the head is
  not recorded as seen, and the merge queue retries instead of landing
  the candidate untested.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Author
Owner
@krisbuild r+
tests: name the fake forge's listed-comment tuple (clippy::type_complexity)
Some checks failed
krisbuild/kris/krisbuild/nix/workspace-deps cached
krisbuild/kris/krisbuild/nix/hello cached
krisbuild/kris/krisbuild/nix/world cached
krisbuild/kris/krisbuild/nix/test failed on nuxbox (exit-code)
krisbuild/kris/krisbuild/nix/build failed on nuxbox (exit-code)
krisbuild/kris/krisbuild/nix/kb-check dependency-propagated failure
krisbuild/kris/krisbuild/nix/clippy failed on nuxbox (exit-code)
krisbuild/kris/krisbuild krisbuild kris/krisbuild: one or more tasks failed
krisbuild/queue queued (#15 in line)
b11801ad87
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Collaborator

Removed from the merge queue: the pull-request head changed.

Removed from the merge queue: the pull-request head changed.
Author
Owner
@krisbuild r+
Author
Owner
@krisbuild r-
Merge origin/main into lu/forge-outbox (single integration-test binary)
Some checks failed
krisbuild/kris/krisbuild/nix/hello cached
krisbuild/kris/krisbuild/nix/workspace-deps cached
krisbuild/kris/krisbuild/nix/test-deps cached
krisbuild/kris/krisbuild/nix/world cached
krisbuild/kris/krisbuild/nix/test cached
krisbuild/kris/krisbuild/nix/build cached
krisbuild/kris/krisbuild/nix/clippy cached
krisbuild/kris/krisbuild/nix/kb-check succeeded
krisbuild/kris/krisbuild krisbuild kris/krisbuild: all tasks succeeded
krisbuild/queue the merge conflicts in SPEC.md, crates/kb-control-plane/src/db.rs, crates/kb-control-plane/src/scheduler/status.rs, crat
db26582a69
The forge outbox and reconcile suites move into tests/it/ as modules; the
fake forge's outbox and listing helpers merge with the git smart-HTTP
additions in it/common.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Author
Owner
@krisbuild r+
Collaborator

The pull request head's own build failed (graph 611).

  • nix/kb-check: exit-code on nuxbox, exit 1 — log

Push a fix, or comment @krisbuild retry to rerun the failed tasks and requeue.

The pull request head's own build failed ([graph 611](http://192.168.0.2:1337/ui/graphs/611)). - `nix/kb-check`: exit-code on nuxbox, exit 1 — [log](http://192.168.0.2:1337/ui/instances/17568) Push a fix, or comment `@krisbuild retry` to rerun the failed tasks and requeue.
Author
Owner

Head CI failed only because nix/kb-check's .drv was missing from attic (the eval-push race, fixed in the deployment since). Pushed the missing drvs and reran the failed tasks: graph 646 is green.

@krisbuild retry

Head CI failed only because `nix/kb-check`'s .drv was missing from attic (the eval-push race, fixed in the deployment since). Pushed the missing drvs and reran the failed tasks: graph 646 is green. @krisbuild retry
Collaborator

Removed from the merge queue: the merge conflicts in SPEC.md, crates/kb-control-plane/src/db.rs, crates/kb-control-plane/src/scheduler/status.rs, crates/kb-control-plane/src/state.rs.

Removed from the merge queue: the merge conflicts in SPEC.md, crates/kb-control-plane/src/db.rs, crates/kb-control-plane/src/scheduler/status.rs, crates/kb-control-plane/src/state.rs.
Merge origin/main into lu/forge-outbox (node health, agent enrollment)
All checks were successful
krisbuild/kris/krisbuild/nix/test-deps cached
krisbuild/kris/krisbuild/nix/workspace-deps cached
krisbuild/kris/krisbuild/nix/hello cached
krisbuild/kris/krisbuild/nix/world cached
krisbuild/kris/krisbuild/nix/clippy succeeded
krisbuild/kris/krisbuild/nix/test succeeded
krisbuild/kris/krisbuild/nix/build succeeded
krisbuild/kris/krisbuild/nix/kb-check succeeded
krisbuild/kris/krisbuild krisbuild kris/krisbuild: all tasks succeeded
krisbuild/queue merged
c9dbb62f40
Conflicts: SPEC.md and db.rs (agent_enrollments beside the forge tables;
both kept), state.rs and scheduler/status.rs. The node-health status
behaviour — infra vs verdict descriptions, a requeued attempt re-posting
`pending` ("... (retrying on another node)") — now runs through the
outbox sweep: the per-task bookkeeping is keyed on (state, attempt), and
completion enqueues the final statuses in its own transaction as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Author
Owner
@krisbuild r+
krisbuild manually merged commit 3e02404503 into main 2026-09-27 22:37:40 +02:00
Collaborator

Merged as 3e02404503.

Merged as 3e024045033a.
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kris/krisbuild!14
No description provided.