Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,38 @@ All notable changes to loopctl are documented here.

### Added

- **A thread-mode merge requires green CI on the checkpoint's exact commit (epic 45, US-45.6,
migration `20260927100000`). A THREAD-mode source must now name its `required_checks`, and
the gate's `GITHUB_TOKEN` needs `actions: read` (and, best effort, `commit statuses: read`)
for it.** The
migration adds `intake_sources.required_checks` (`varchar(255)[]`, NOT NULL, default empty; no
backfill). `POST`/`PATCH /api/v1/intake/sources` and the `intake_source_enroll` /
`intake_source_update` MCP tools take it; a `thread` source naming none is 422, judged over
the source as it will be, `local-gate` is refused, and a change is recorded as
`intake_source_required_checks_set` on the audit chain. **A thread-mode source enrolled
before this migration has no required checks and every one of its stories is refused
`required_checks_unset` until one is named.** Name GitHub Actions JOB names, and make
sure each required job runs on every push to the thread branches (no path filter or
job-level `if:` that can skip it): a required check that never appears is refused after the
wait. The merge gate trusts ONLY the jobs of the GitHub Actions workflow runs that a PUSH
of the thread branch at the checkpoint's exact commit triggered (the Actions runs and jobs
APIs; the gate's `GITHUB_TOKEN` needs `actions: read`), and a job passes only by
concluding `success` — a skipped job fails. Commit statuses and check runs created any
other way are never trusted, because the implementer can create them; a CI that reports
only statuses cannot satisfy thread mode. Per name, the newest run of each workflow counts
and every workflow must pass. A failed one refuses `required_check_failed`; one still
running or not yet reported answers `unevaluated` (`required_check_pending` /
`required_check_missing`), never counted toward the unevaluated bound, and refused
`required_check_timed_out` once the wait passes `MergePrecondition.ci_wait_limit_seconds/0`
from the story's entry into `ci`. A checkpoint that changes `.github/workflows/` or
`.github/actions/` is refused `ci_definition_changed` for a human (`ci_definition_unknown`
when its diff could not be listed), because Actions runs the workflow files of the commit
under test. A failed read is `ci_evidence_unavailable`. The list is read from
the source live, so correcting it reaches stories already in flight. A
`local-gate` status is recorded and never satisfies a required check. What was read is
returned as `ci_evidence` and copied onto the checkpoint's `gate_evidence` under `ci`; an
allow whose copy did not land is refused `ci_evidence_not_recorded`. MCP server 2.108.0.

- **Review on a change thread (epic 45, US-45.3, runner contract 1.21.0, migration
`20260926160000`). RE-VENDOR the contract and declare `review` to take review dispatches.**
`POST /api/v1/stories/:id/thread/reviews` (orchestrator or above, human-anchored tenants)
Expand Down
2 changes: 1 addition & 1 deletion deploy/FLY_SECRETS.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ during an incident with `fly secrets set … && fly apps restart` — no deploy.

| Variable | Default | Description |
|----------------|---------|-------------|
| `GITHUB_TOKEN` | - | Bearer token for the CI status/test-result lookups that back independent story verification, AND (#803) for the merge precondition's reads of a pull request's state, diffstat, changed names and file tree. Optional: unset, the calls go out unauthenticated, which works for PUBLIC repos until GitHub's 60-requests/hour/IP anonymous limit bites — after that verification reports a `github_api_error` rather than a real CI verdict, and the merge precondition answers `unevaluated` (HTTP 503) rather than merging anything — an exhausted quota is transient, so the loop RETRIES rather than escalating, and only escalates once the same story has been unevaluable at one head several times running. Either way nothing merges without the token. Required for a private repo, where unauthenticated lookups 404. Needs only read access to checks, pull requests and contents. A blank value is treated as unset (it is trimmed), so a templated-but-empty secret degrades to the anonymous path rather than sending an empty bearer that GitHub 401s. **Since #805 it also needs `issues: write`** — the only WRITE scope loopctl asks for. That is what lets the delivery loop close the GitHub issue a story came from, with a `loopctl:resolution-*` label and a comment naming the outcome; without it those calls 403, which is NOT a rate limit and NOT transient, so the closure is recorded `abandoned` with `permanent_forge_failure` on its first attempt and the reporter is never told what happened to her issue. Nothing else breaks and nothing is retried in a loop. **Recovering the backlog after you fix the token:** `SELECT abandoned_reason, date_trunc('hour', updated_at) AS at, count(*) FROM intake_issue_closures WHERE status = 'abandoned' GROUP BY 1, 2 ORDER BY 2 DESC;` shows the damage and WHEN it happened. Then, from a remote console, count before you write and requeue only the window you just fixed — `IssueClosures.requeue_abandoned(abandoned_after: ~U[YYYY-MM-DDThh:mm:00Z], dry_run: true)`, then the same call without `dry_run`. **An unbounded call is refused** (`{:error, :bound_required}`) and `tenant_id:` is not a bound: a closure abandoned months ago by an unrelated outage still names a live issue, and waking it puts a fresh label, comment and close on a ticket the reporter has long since moved on from. Pass `unbounded: true` to mean it. It never re-drives a `closed_by_other` or `source_revoked` row — a human already closed that issue, or the tenant disconnected the repository |
| `GITHUB_TOKEN` | - | Bearer token for the CI status/test-result lookups that back independent story verification, AND (#803) for the merge precondition's reads of a pull request's state, diffstat, changed names and file tree. Optional: unset, the calls go out unauthenticated, which works for PUBLIC repos until GitHub's 60-requests/hour/IP anonymous limit bites — after that verification reports a `github_api_error` rather than a real CI verdict, and the merge precondition answers `unevaluated` (HTTP 503) rather than merging anything — an exhausted quota is transient, so the loop RETRIES rather than escalating, and only escalates once the same story has been unevaluable at one head several times running. Either way nothing merges without the token. Required for a private repo, where unauthenticated lookups 404. Needs only read access to checks, pull requests and contents. A blank value is treated as unset (it is trimmed), so a templated-but-empty secret degrades to the anonymous path rather than sending an empty bearer that GitHub 401s. **Since US-45.6 a THREAD-mode source also needs `actions: read`**: the merge gate reads the workflow runs a push of the thread branch triggered, and their jobs, for a checkpoint's exact commit, and without it the read is refused `ci_evidence_unavailable` and nothing thread-mode merges. `commit statuses: read` is read best effort, only to record the `local-gate` state. A pr-mode source never reads either. **Since #805 it also needs `issues: write`** — the only WRITE scope loopctl asks for. That is what lets the delivery loop close the GitHub issue a story came from, with a `loopctl:resolution-*` label and a comment naming the outcome; without it those calls 403, which is NOT a rate limit and NOT transient, so the closure is recorded `abandoned` with `permanent_forge_failure` on its first attempt and the reporter is never told what happened to her issue. Nothing else breaks and nothing is retried in a loop. **Recovering the backlog after you fix the token:** `SELECT abandoned_reason, date_trunc('hour', updated_at) AS at, count(*) FROM intake_issue_closures WHERE status = 'abandoned' GROUP BY 1, 2 ORDER BY 2 DESC;` shows the damage and WHEN it happened. Then, from a remote console, count before you write and requeue only the window you just fixed — `IssueClosures.requeue_abandoned(abandoned_after: ~U[YYYY-MM-DDThh:mm:00Z], dry_run: true)`, then the same call without `dry_run`. **An unbounded call is refused** (`{:error, :bound_required}`) and `tenant_id:` is not a bound: a closure abandoned months ago by an unrelated outage still names a live issue, and waking it puts a fresh label, comment and close on a ticket the reporter has long since moved on from. Pass `unbounded: true` to mean it. It never re-drives a `closed_by_other` or `source_revoked` row — a human already closed that issue, or the tenant disconnected the repository |

#### Post-deploy verification (#803 §9)

Expand Down
2 changes: 1 addition & 1 deletion docs/agent-delivery-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ Before a merge, an orchestrator or operator calls `merge_precondition` (`POST /a
| `head_moved` | The pull request's head changed since CI ran. The story goes back to `implementing` over `base_moved`, and the recorded head is cleared, so the gate is not called again until the story is back at `ci`. |
| `unevaluated` | 503 with `Retry-After`. After repeated `unevaluated` answers the story escalates. |

**Thread mode (epic 45, US-45.4).** A story whose claim was PLACED under an intake source with `mode: thread` (`intake_source_update`) has no pull request. The mode, and the base branch, are recorded on the implement dispatch at placement, so changing the source affects only stories placed afterwards; a change is always allowed. The gate judges the latest checkpoint the story's CURRENT claim recorded, on the branch that claim's dispatch ran on and against the base branch it was placed on, needs no `pr_number`, and also refuses `empty_change` (the checkpoint's tree equals the base's, or no file changed), `checkpoint_tree_mismatch`, `no_checkpoint_recorded`, `claim_ended` (the current claim recorded nothing but an earlier, released one did), and `thread_unreadable` (loopctl could not read the thread). The branch is judged first: a branch missing from a readable repository (`branch_missing`), one naming a commit nobody reported (`branch_head_unrecorded`), one naming an earlier checkpoint of the claim (`branch_head_regressed`), or a checkpoint that is not the recorded head means the head moved. The diff judged is the checkpoint's three-dot diff against its merge base, so a base that moved on is not a refusal, and a checkpoint the base already contains, its branch deleted or not, is `already_merged` only under a recorded allow naming it, and otherwise refused `checkpoint_on_base_without_allow`. A claim with no accepted dispatch (a session that claimed the story itself) is judged as a pull request against the source's current base branch, whatever the source's mode. **While the claim is live** that is `head_moved`, back to `implementing` like any moved head. **When it is not live** (reported, review requested, lease expired) the claimant cannot record a fix, so the gate refuses naming `claim_not_live` and the story escalates instead of looping. A repository the token cannot read refuses `pull_request_unavailable`. An allow is recorded naming the checkpoint id and sha and `base_sha`, the merge base the judged diff is relative to. **Thread mode needs the source's runners at runner contract 1.20.0 or later sending `checkpoint` messages; otherwise every story is refused `no_checkpoint_recorded`.** The merge executor (US-45.5) merges only while the base head still equals that `base_sha`, and otherwise takes its base-update path (US-45.5). This gate judges claimant checkpoints only; reading the executor's `base_update` checkpoints is US-45.5's (AC-45.5.9).
**Thread mode (epic 45, US-45.4).** A story whose claim was PLACED under an intake source with `mode: thread` (`intake_source_update`) has no pull request. The mode, and the base branch, are recorded on the implement dispatch at placement, so changing the source affects only stories placed afterwards; a change is always allowed. The gate judges the latest checkpoint the story's CURRENT claim recorded, on the branch that claim's dispatch ran on and against the base branch it was placed on, needs no `pr_number`, and also refuses `empty_change` (the checkpoint's tree equals the base's, or no file changed), `checkpoint_tree_mismatch`, `no_checkpoint_recorded`, `claim_ended` (the current claim recorded nothing but an earlier, released one did), and `thread_unreadable` (loopctl could not read the thread). The branch is judged first: a branch missing from a readable repository (`branch_missing`), one naming a commit nobody reported (`branch_head_unrecorded`), one naming an earlier checkpoint of the claim (`branch_head_regressed`), or a checkpoint that is not the recorded head means the head moved. The diff judged is the checkpoint's three-dot diff against its merge base, so a base that moved on is not a refusal, and a checkpoint the base already contains, its branch deleted or not, is `already_merged` only under a recorded allow naming it, and otherwise refused `checkpoint_on_base_without_allow`. A claim with no accepted dispatch (a session that claimed the story itself) is judged as a pull request against the source's current base branch, whatever the source's mode. **While the claim is live** that is `head_moved`, back to `implementing` like any moved head. **When it is not live** (reported, review requested, lease expired) the claimant cannot record a fix, so the gate refuses naming `claim_not_live` and the story escalates instead of looping. A repository the token cannot read refuses `pull_request_unavailable`. An allow is recorded naming the checkpoint id and sha and `base_sha`, the merge base the judged diff is relative to. **Thread mode needs the source's runners at runner contract 1.20.0 or later sending `checkpoint` messages; otherwise every story is refused `no_checkpoint_recorded`.** **CI is read by the checkpoint's exact SHA (US-45.6)** (only a job of a GitHub Actions workflow run that a push of the thread branch at that commit triggered satisfies a required check, and only by concluding `success` — a skipped job fails; commit statuses and check runs created any other way are never trusted, because the implementer can create them), against the source's `required_checks` (a thread source must name at least one; set with `intake_source_update`): a failed one refuses `required_check_failed`; one still running or not yet reported answers `unevaluated` with a `Retry-After` and never counts toward the unevaluated bound; past the gate's CI wait limit from the story's entry into `ci` both are refused `required_check_timed_out`, so a slow pipeline waits and a stuck or missing check still reaches a human. Per name the newest run of each workflow counts and every workflow must pass; the required checks are the source's current list, so each required job must run on every push to the thread branches; and a checkpoint changing `.github/workflows/` or `.github/actions/` is refused `ci_definition_changed` for a human, because Actions runs the workflow files of the commit under test; a source requiring none refuses `required_checks_unset`. A `local-gate` status is recorded on the checkpoint and never satisfies a required check, because whoever pushed posts it. What was read is copied onto the checkpoint's `gate_evidence` under `ci`. The merge executor (US-45.5) merges only while the base head still equals that `base_sha`, and otherwise takes its base-update path (US-45.5). This gate judges claimant checkpoints only; reading the executor's `base_update` checkpoints is US-45.5's (AC-45.5.9).

## 7. After the merge

Expand Down
Loading
Loading