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
41 changes: 41 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,47 @@ All notable changes to loopctl are documented here.

### Added

- **Intake sources carry a merge mode, and a `thread`-mode story merges from a recorded
checkpoint with no pull request (epic 45, US-45.4). Migration `20260926140000` adds
`intake_sources.mode`, NOT NULL, default `pr`; no backfill and no manual step, and every
existing source behaves exactly as before.** `POST` and `PATCH /api/v1/intake/sources`
(and the `intake_source_enroll` / `intake_source_update` MCP tools) take `mode: "pr" |
"thread"`, read by presence; null or any other value is 422, and a change is recorded as
`intake_source_mode_set` on the audit chain. For a thread-mode story,
`POST /stories/:id/merge-precondition` judges the latest checkpoint the story's CURRENT
claim recorded, on the branch that claim's dispatch ran on (`pr_number` is null). **A
thread-mode source needs runners at contract 1.20.0 or later sending `checkpoint`
messages; otherwise every story is refused `no_checkpoint_recorded`.** It adds refusals
`empty_change` (the checkpoint's tree equals the base branch'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). A branch missing from a readable
repository (`branch_missing`), naming a commit nobody reported (`branch_head_unrecorded`),
naming an earlier checkpoint of the claim (`branch_head_regressed`), or a checkpoint that
is not the recorded head, is `head_moved` back to `implementing` while the claim is live,
and a refusal naming `claim_not_live` (escalated) when it is not. The diff judged is the
checkpoint's three-dot diff against its merge base, so a base that moved on is not a
refusal. 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`. An unreadable repository
refuses `pull_request_unavailable`. The gate's token also reads `GET /repos/:repo` after a
404 on the branch. The verdict carries `mode`, `checkpoint_id`, `checkpoint_sha` and
`base_sha` (the merge base the judged diff is relative to), and a thread-mode allow is
recorded naming the checkpoint id and sha and that `base_sha`; the merge executor
(US-45.5) merges only while the base head still equals it. No stage-machine change; the runner contract is
unchanged.
**The mode and base branch are bound at placement.** Migration `20260926150000` adds
`runner_dispatches.mode` and `runner_dispatches.base_branch` (nullable, no backfill, no
manual step): an implement dispatch records its intake source's mode and the base branch it
was sent with when it is first sent, and the merge gate reads both from the current claim's
dispatch, a NULL mode meaning `pr` and a NULL base branch meaning the source's current one;
a claim with no accepted dispatch at all is judged as a pull request against the source's
current base branch, so flipping a source to thread never re-routes a session's own PR.
Changing a source's mode or base branch is therefore always allowed and affects only stories
placed afterwards.
For a thread-mode repository the
gate's `GITHUB_TOKEN` also reads `git/ref/heads/*` and `git/commits/*` (contents: read,
which the tree reads already need).
- **Runners may report checkpoints and notes on a story's change thread (epic 45, US-45.2,
runner contract 1.20.0). RE-VENDOR the contract to send them; a runner that does not gets
today's behaviour.** Two new optional channel messages. `checkpoint` carries `{dispatch_id,
Expand Down
2 changes: 2 additions & 0 deletions docs/agent-delivery-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ 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).

## 7. After the merge

- **`deployed` to `verified`, or to `escalated`:** `PostDeployVerification` compares the merge commit with the repository's deployment records.
Expand Down
45 changes: 5 additions & 40 deletions docs/user_stories/epic_45_change_threads/us_45.4.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,23 +16,19 @@
"acceptance_criteria": [
{
"id": "AC-45.4.1",
"description": "An intake source carries a mode, pr or thread, defaulting to pr; existing sources behave exactly as before; intake_source_update and intake_source_enroll set the mode through their MCP tools."
"description": "An intake source carries a mode, pr or thread, defaulting to pr, settable through the intake_source_enroll and intake_source_update MCP tools. The mode is bound per implement dispatch at placement: the dispatch's ledger row records the source's mode, and the base branch it was sent with, when it is first sent, and the merge gate reads both from the story's current claim's dispatch, with a legacy row read as pr and its base branch falling back to the source's current one, and a claim with no accepted implement dispatch judged as pr against the source's current base branch. A mode or base-branch change is always allowed and affects only later placements, so existing stories behave exactly as before."
},
{
"id": "AC-45.4.2",
"description": "For a thread-mode story, MergePrecondition evaluates the latest recorded checkpoint through a checkpoint implementation of the PullRequestSource facts, needing no pr_number."
},
{
"id": "AC-45.4.3",
"description": "The gate refuses branch_head_unrecorded when the thread branch head is not the latest recorded checkpoint, and empty_change when the checkpoint tree equals the base tree."
},
{
"id": "AC-45.4.4",
"description": "The stage machine has a ci to ci base_updated edge taken only for a base_update checkpoint the control plane recorded, whose first parent is the checkpoint the gate last allowed and whose tree is GitHub's clean merge of the base into it. The edge replaces head_sha with the base_update SHA and clears merge_gate_allowed_sha, keeps the story's review verdict and custody binding, and does not send the story to implementing; any other head movement still goes over base_moved."
"description": "When the thread branch is missing from a repository the token can read, names a commit nobody recorded, names an earlier checkpoint of the current claim, or the latest checkpoint is not the head the stage recorded, the head has moved (branch_missing, branch_head_unrecorded, branch_head_regressed): while the claim is live the gate answers head_moved and returns the story to implementing over base_moved without escalating; when the claim is not live it refuses naming claim_not_live and escalates, because nobody can record the fix. The diff judged is the checkpoint's three-dot diff against its merge base, so a base that moved on is not a refusal; a checkpoint the base already contains, its branch deleted or not, is already_merged only under a recorded allow naming it and is otherwise refused checkpoint_on_base_without_allow, never ungated_merge. It refuses empty_change when the checkpoint tree equals the base tree or the comparison lists no changed file, claim_ended when the current claim recorded no checkpoint but an earlier claim did, and thread_unreadable when the thread's checkpoints could not be read for a reason other than contention."
},
{
"id": "AC-45.4.5",
"description": "A recorded allow names the checkpoint id and its SHA."
"description": "A recorded allow names the checkpoint id and its SHA, and base_sha: the merge base the judged diff is relative to, which the merge executor (US-45.5, AC-45.5.8) must still find at the base head."
}
],
"test_cases": [
Expand Down Expand Up @@ -83,42 +79,11 @@
"expected_results": [
"each refusal code"
]
},
{
"id": "TC-45.4.4",
"verifies": [
"AC-45.4.4"
],
"name": "base_updated edge",
"type": "unit",
"preconditions": [],
"steps": [
"transition ci to ci over base_updated"
],
"expected_results": [
"allowed"
]
},
{
"id": "TC-45.4.5",
"verifies": [
"AC-45.4.4"
],
"name": "base_updated keeps review",
"type": "integration",
"preconditions": [
"thread story allowed at checkpoint C"
],
"steps": [
"record a base_update merge of master into C; re-run the gate"
],
"expected_results": [
"story stays at ci; allow on the new head without a new review round"
]
}
],
"technical_notes": [
"Largest change in the epic."
"Largest change in the epic.",
"The base_update edge (formerly AC-45.4.4) moved to US-45.5 as AC-45.5.7: it has no caller until the merge executor, and cannot be judged safely until US-45.6's CI evidence by exact SHA."
],
"dependencies": [
"US-45.1"
Expand Down
83 changes: 82 additions & 1 deletion docs/user_stories/epic_45_change_threads/us_45.5.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@
{
"id": "AC-45.5.6",
"description": "The App's credentials are new environment variables documented in deploy/FLY_SECRETS.md; with them unset the worker refuses and escalates rather than merging."
},
{
"id": "AC-45.5.7",
"description": "The stage machine has a ci to ci base_updated edge taken only for a base_update checkpoint the control plane recorded, whose first parent is the checkpoint the gate last allowed and whose tree is GitHub's clean merge of the base into it. The edge replaces head_sha with the base_update SHA and clears merge_gate_allowed_sha, keeps the story's review verdict and custody binding, and does not send the story to implementing; any other head movement still goes over base_moved. The edge must not allow a base_update head until US-45.6's CI evidence for that exact SHA is green. (Moved from US-45.4, where it was AC-45.4.4.)"
},
{
"id": "AC-45.5.8",
"description": "The executor merges only while the base branch's head still equals the base_sha the gate's allow recorded (US-45.4: the merge base the judged three-dot diff is relative to). The gate does not refuse a base that moved on, so this is the one place base freshness is enforced: when the base head differs, the executor takes the base-update path (AC-45.5.4, AC-45.5.7) and the story comes back through the gate (AC-45.5.9), because squashing the checkpoint's tree onto a newer base would silently revert every base commit the judged diff never saw."
},
{
"id": "AC-45.5.9",
"description": "When base_update checkpoints arrive, Threads.claim_checkpoints treats the latest base_update of the current claim whose ancestry reaches the checkpoint the gate last allowed as the judged head, so the story comes back through the merge gate (US-45.4) on it. Until then the gate reads claimant checkpoints (kind checkpoint) only, and a base_update is invisible to it."
}
],
"test_cases": [
Expand Down Expand Up @@ -136,13 +148,82 @@
"expected_results": [
"escalated with tree_mismatch; no ref update"
]
},
{
"id": "TC-45.5.7",
"verifies": [
"AC-45.5.7"
],
"name": "base_updated edge",
"type": "unit",
"preconditions": [],
"steps": [
"transition ci to ci over base_updated"
],
"expected_results": [
"allowed"
]
},
{
"id": "TC-45.5.8",
"verifies": [
"AC-45.5.7"
],
"name": "base_updated keeps review",
"type": "integration",
"preconditions": [
"thread story allowed at checkpoint C"
],
"steps": [
"record a base_update merge of master into C; re-run the gate"
],
"expected_results": [
"story stays at ci; allow on the new head without a new review round"
]
},
{
"id": "TC-45.5.9",
"verifies": [
"AC-45.5.8"
],
"name": "base moved after the allow",
"type": "integration",
"preconditions": [
"mocked forge",
"a recorded thread-mode allow whose base_sha is not the base head"
],
"steps": [
"run the executor"
],
"expected_results": [
"no squash commit and no ref update; the base-update path runs"
]
},
{
"id": "TC-45.5.10",
"verifies": [
"AC-45.5.9"
],
"name": "gate judges the base update",
"type": "integration",
"preconditions": [
"a recorded allow for checkpoint C",
"a base_update checkpoint whose first parent is C"
],
"steps": [
"run the merge gate"
],
"expected_results": [
"the base_update is the judged head; an unrelated base_update is not"
]
}
],
"technical_notes": [
"Forge access through a behaviour with a Mox mock in test.",
"The base_update checkpoint is written by a control-plane function with no HTTP route, never through the claimant-fenced checkpoint endpoint."
],
"dependencies": [
"US-45.4"
"US-45.4",
"US-45.6"
]
}
Loading
Loading