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
12 changes: 8 additions & 4 deletions batch/lib/kernel-client.sh
Original file line number Diff line number Diff line change
Expand Up @@ -852,9 +852,9 @@ _kernel_record_ci() { # <run_id> <generation> <status> <head_git_sha>
# implementation output; the kernel refuses a review whose phase is not the
# phase of the state it is recorded from. The front half's spec and plan
# reviews do not come through here.
_kernel_record_review() { # <run_id> <generation> <verdict> <artifact> <base> <context> [key] [terminal]
_kernel_record_review() { # <run_id> <generation> <verdict> <artifact> <base> <context> [key] [terminal] [head_sha] [merge_base_sha] [delta_digest]
local run_id="$1" generation="$2" raw="$3" artifact="$4" base="$5" context="$6"
local key="${7:-}" terminal="${8:-}"
local key="${7:-}" terminal="${8:-}" head_sha="${9:-}" merge_base="${10:-}" digest="${11:-}"
local verdict; verdict=$(_kernel_verdict "$raw" "$terminal")
# No early return: every input now maps to something submittable -- a mapped
# verdict, or `unmapped:...` which the kernel refuses visibly. The guard that
Expand All @@ -865,18 +865,22 @@ _kernel_record_review() { # <run_id> <generation> <verdict> <artifact> <base> <
_kernel_warn "incomplete verdict binding (artifact='$artifact' base='$base' context='$context') -- not recording a review"
return 0
fi
# What was reviewed (spec §4): included only when the derivation pinned a
# head. Values are shas and a hex digest, so they need no JSON escaping.
local range=""
[ -n "$head_sha" ] && range="\"head_sha\":\"$head_sha\",\"merge_base_sha\":\"$merge_base\",\"delta_digest\":\"$digest\","
# An empty key must not become `--idempotency-key ""`: the CLI would take the
# empty string as the key rather than falling back to its default, and every
# record_review in the run would collide on it.
if [ -n "$key" ]; then
_kernel command --run-id "$run_id" --generation "$generation" \
--name record_review --idempotency-key "$key" \
--payload-json "{\"verdict\":\"$verdict\",\"phase\":\"implementation\",\"artifact_hash\":\"$artifact\",\"base_sha\":\"$base\",\"context_bundle_hash\":\"$context\",\"policy_version\":$_KERNEL_POLICY_VERSION}"
--payload-json "{\"verdict\":\"$verdict\",\"phase\":\"implementation\",\"artifact_hash\":\"$artifact\",\"base_sha\":\"$base\",\"context_bundle_hash\":\"$context\",${range}\"policy_version\":$_KERNEL_POLICY_VERSION}"
return 0
fi
_kernel command --run-id "$run_id" --generation "$generation" \
--name record_review \
--payload-json "{\"verdict\":\"$verdict\",\"phase\":\"implementation\",\"artifact_hash\":\"$artifact\",\"base_sha\":\"$base\",\"context_bundle_hash\":\"$context\",\"policy_version\":$_KERNEL_POLICY_VERSION}"
--payload-json "{\"verdict\":\"$verdict\",\"phase\":\"implementation\",\"artifact_hash\":\"$artifact\",\"base_sha\":\"$base\",\"context_bundle_hash\":\"$context\",${range}\"policy_version\":$_KERNEL_POLICY_VERSION}"
}

# _kernel_request_merge <run_id> <generation> <pr> <repo> <head_git_sha>
Expand Down
359 changes: 169 additions & 190 deletions batch/run-queue.sh

Large diffs are not rendered by default.

27 changes: 21 additions & 6 deletions docs/design/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,16 @@ session has settled and exits when the tuple is printed.
| `human.py` | human interaction: the cursor, the discriminator, the batch rules, dismissals and their replies |
| `phases.py` | the front-half loop itself: `retire_owed`, `publish_owed`, parking and resumption; a shaping round before the spec — a one-piece ruling or a slice plan (`coordinator/shape.py`); the slice plan's review and gate; `file_owed` (`coordinator/filing.py`), which files an accepted plan's children in four idempotent passes; and `sweep_sliced` (`coordinator/sweep.py`), run by each wave before it generates the queue |

It returns an eight-field pipe-delimited tuple:
It returns a ten-field pipe-delimited tuple:

outcome|review|note|sha|ci|ci_first|resubmissions|pr
outcome|review|note|sha|ci|ci_first|resubmissions|pr|merge_base|delta_digest

The last two are what the reviewer actually read (gate integrity, spec §4):
`merge_base` is the merge-base of the PR's base branch with the reviewed head,
and `delta_digest` is the digest of the PR's own three-dot delta at that head.
The runner records both on the `review_verdict` fact, so a verdict says what it
covered rather than only that it happened; the digest is also what lets a
re-stamp after an `update-branch` prove the reviewed content is unchanged.

> **TARGET —** the coordinator is the orchestrator. No tuple, no subprocess
> boundary, no pipe-delimited transport: the derivation's result is an object
Expand Down Expand Up @@ -456,9 +463,17 @@ Mode defaults are already `BIRCHER_EFFECT_MODE=kernel` and
`BIRCHER_KERNEL_MODE=enforce`. `--source queue` drains `queue/*.md` instead.

**The merge gate on muesli:** branch protection requires `review-gate`, NOT
`bircher/cross-review`. They chain — bircher posts cross-review, a workflow
reacts to that status event and posts review-gate ~7s later. `strict: true` is
set, so a PR whose base has moved becomes `BEHIND` and is refused.
`bircher/cross-review`. `bircher/cross-review` is posted by the derivation for
every verdict on the reviewed head: `success` for PASS, `failure` for FAIL
with the blocking count, `error` when no verdict was produced, with `<vendor>
round <n> ... on <merge-base7>..<head7>` as the description; the merge path
re-posts only when no success is present, and a re-stamp after a
content-identical update-branch keeps the reviewed description. The target
repository's `review-gate` workflow reacts to that status event and posts its
own `review-gate` status on the head ~7s later, requiring `success` on
`bircher/cross-review`; it reads the state only, never the description.
`strict: true` is set, so a PR whose base has moved becomes `BEHIND` and is
refused.

---

Expand Down Expand Up @@ -682,5 +697,5 @@ programme exists to catch.
**What is NOT a gap.** These are done and should not be reopened: the kernel's
state machine, effect classes, fact vocabulary and mode switches; the derived
outcome replacing the marker as the decision input; effect routing with its
enumerating guards; the pre-merge gate; the eight-field boundary's fail-closed
enumerating guards; the pre-merge gate; the ten-field boundary's fail-closed
width check.
44 changes: 23 additions & 21 deletions docs/design/effect-site-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,19 +36,19 @@ grep -nE "gh .*--add-label|--remove-label" batch/run-queue.sh
| 1095 | `curl -X POST $SERVER/v1/sessions/$1/events` (send prompt) | `session_control` |
| 1107 | `curl -X POST $SERVER/v1/sessions/$1/events` (stop_session) | `session_control` |
| 1121 | `curl -X DELETE $SERVER/v1/sessions/$1` | `session_control` |
| 1198 | `gh api repos/$REPO/statuses/$sha -X POST` | `status_check` |
| 1662 | `gh pr merge --squash --delete-branch` | `merge` |
| 1866 | `git push origin HEAD:main` | `ref_update` |
| 2048 | `gh api repos/$REPO/pulls/$pr/update-branch -X PUT -f expected_head_sha=$sha` (sweep) | `ref_update` |
| 2192 | `git push origin $oid:refs/heads/$branch` (publish) | `ref_update` |
| 2196 | `gh pr create --head $branch --base main` (publish) | `pull_request` |
| 2393 | `gh api repos/$REPO/pulls/$pr/update-branch -X PUT` (recover-pr) | `ref_update` |
| 2643 | `gh pr close` | `pull_request` |
| 3881 | `gh issue comment` | `comment` |
| 3882 | `gh issue edit --remove-label` | `issue_or_label` |
| 3883 | `gh issue edit --add-label` | `issue_or_label` |
| 3899 | `gh issue close` | `issue_or_label` |
| 4508 | `gh issue edit --add-label bircher:running` | `issue_or_label` |
| 1210 | `gh api repos/$REPO/statuses/$sha -X POST` | `status_check` |
| 1645 | `gh pr merge --squash --delete-branch` | `merge` |
| 1849 | `git push origin HEAD:main` | `ref_update` |
| 2031 | `gh api repos/$REPO/pulls/$pr/update-branch -X PUT -f expected_head_sha=$sha` (sweep) | `ref_update` |
| 2175 | `git push origin $oid:refs/heads/$branch` (publish) | `ref_update` |
| 2179 | `gh pr create --head $branch --base main` (publish) | `pull_request` |
| 2376 | `gh api repos/$REPO/pulls/$pr/update-branch -X PUT` (recover-pr) | `ref_update` |
| 2589 | `gh pr close` | `pull_request` |
| 3828 | `gh issue comment` | `comment` |
| 3829 | `gh issue edit --remove-label` | `issue_or_label` |
| 3830 | `gh issue edit --add-label` | `issue_or_label` |
| 3846 | `gh issue close` | `issue_or_label` |
| 4456 | `gh issue edit --add-label bircher:running` | `issue_or_label` |

**1519 is `merge`, not `pull_request`.** An earlier draft of the M1-4 plan
classified it as `pull_request`. M1-3 split `merge` into its own class
Expand All @@ -67,11 +67,11 @@ suppression nobody wrote down is a suppression nobody re-reads.

| Line | Text | Why it is not a call |
|---|---|---|
| 1689 | `MERGE_NOTE="merge deferred: gh pr merge failed"` | assignment value |
| 7357 | `[ "$MERGE_NOTE" = "merge deferred: gh pr merge failed" ]` | string comparison |
| 8299 | `_contains "$_body" '_effect ref_update … git push origin'` | selftest asserting the source contains it |
| 8300 | `echo "FAIL #62: the recovery git push must be routed AND bounded"` | failure message |
| 8323 | `echo "FAIL #62: … a git push that ignores SIGTERM …"` | failure message |
| 1672 | `MERGE_NOTE="merge deferred: gh pr merge failed"` | assignment value |
| 7356 | `[ "$MERGE_NOTE" = "merge deferred: gh pr merge failed" ]` | string comparison |
| 8272 | `_contains "$_body" '_effect ref_update … git push origin'` | selftest asserting the source contains it |
| 8273 | `echo "FAIL #62: the recovery git push must be routed AND bounded"` | failure message |
| 8296 | `echo "FAIL #62: … a git push that ignores SIGTERM …"` | failure message |

## Reads — not journalled

Expand All @@ -91,14 +91,14 @@ named here.

| Line | Call | Class | Why not routed |
|---|---|---|---|
| 3657 | `gh run rerun` | *(none)* | **Re-triggers a workflow. Unrouted, and UNDETECTED until 2026-08-29** — `MUTATION` enumerates verbs and `gh run` was a noun it had never been taught, so a whole command family was invisible. Second instance of that class in one day; the first hid behind a wrapper. Now detected, and enumerated by `test_every_gh_subcommand_is_classified` so a new `gh <noun> <verb>` cannot join silently. **Left unrouted deliberately:** it creates no object and changes no repository content — it re-runs an existing workflow after an INFRASTRUCTURE failure, and its only influence on a kernel decision is via CI status, which the derivation re-observes rather than trusts. Routing it needs an effect class for "trigger a workflow", which the journal does not have — a design decision, not a migration step. Its real cost is CI minutes, bounded by `BIRCHER_CI_RERUN_MAX` (default 4). |
| 3659 | `gh run rerun` | *(none)* | Same site class as the other `gh run rerun` row above — see it for the reasoning. **Named, not numbered**, because the repointing tool remaps the Line column and cannot follow a line number written in prose: this reference had been stale since the row moved to 3457. |
| 3604 | `gh run rerun` | *(none)* | **Re-triggers a workflow. Unrouted, and UNDETECTED until 2026-08-29** — `MUTATION` enumerates verbs and `gh run` was a noun it had never been taught, so a whole command family was invisible. Second instance of that class in one day; the first hid behind a wrapper. Now detected, and enumerated by `test_every_gh_subcommand_is_classified` so a new `gh <noun> <verb>` cannot join silently. **Left unrouted deliberately:** it creates no object and changes no repository content — it re-runs an existing workflow after an INFRASTRUCTURE failure, and its only influence on a kernel decision is via CI status, which the derivation re-observes rather than trusts. Routing it needs an effect class for "trigger a workflow", which the journal does not have — a design decision, not a migration step. Its real cost is CI minutes, bounded by `BIRCHER_CI_RERUN_MAX` (default 4). |
| 3606 | `gh run rerun` | *(none)* | Same site class as the other `gh run rerun` row above — see it for the reasoning. **Named, not numbered**, because the repointing tool remaps the Line column and cannot follow a line number written in prose: this reference had been stale since the row moved to 3457. |

| 1017 | `curl -X POST $SERVER/v1/sessions` | `session_control` | **Its response body is parsed, but that is NOT what blocks routing — corrected 2026-08-29.** The recorded reason was that routing needs the intent contract to carry a response. It does not: `perform` already returns the executor's stdout as the external object id, and `_create_session` was routed on exactly that basis, capturing the response and parsing it afterwards. The ACTUAL blocker is `-w`. The upload uses `-w '\n%{http_code}'` to capture the status alongside the body, and `-w` is deliberately absent from the `session_control` contract because its value supports `%output{path}` — an arbitrary filesystem write from a class that exists to control a session. Checked directly: the contract refuses this argv, reading the `-w` value as the URL. **The route out is `--fail-with-body`** (curl 8.14.1 on the runner supports it), which yields the body AND a non-zero exit on an HTTP error, making `-w` unnecessary. Not done here because it changes what the caller receives on failure, on the coordinator's hot path where every run begins. |

## Kernel-executor sites (not shell)

The shaping phase performs its effects from Python through `perform` (`v2/coordinator/filing.py`, `v2/coordinator/sweep.py`), never from `run-queue.sh`, so the routing detector above has no line to cite. They are listed here so the inventory stays the authority on what mutates:
The shaping phase performs its effects from Python through `perform` (`v2/coordinator/filing.py`, `v2/coordinator/sweep.py`), and the derivation performs its own two through `perform_effect` (`v2/coordinator/outcome.py`), never from `run-queue.sh`, so the routing detector above has no line to cite. They are listed here so the inventory stays the authority on what mutates:

| Where | Call | Effect class | Bound by |
|---|---|---|---|
Expand All @@ -109,3 +109,5 @@ The shaping phase performs its effects from Python through `perform` (`v2/coordi
| `coordinator/filing.py` pass 4 | `gh issue edit <parent> --add-label bircher:sliced --remove-label bircher:running` | `issue_or_label` | `umbrella_label` |
| `coordinator/sweep.py` | `gh issue comment <parent> --body 'bircher: sliced complete…'` | `comment` | `umbrella_close`; `filing_complete` and a same-generation `children_observed_closed` |
| `coordinator/sweep.py` | `gh issue close <parent>` | `issue_or_label` | `parent_close`; as above, and only when the parent was observed open |
| `coordinator/outcome.py` `_post_status` | `gh api repos/<repo>/statuses/<head> -X POST -f state -f context=bircher/cross-review -f description` | `status_check` | the reviewed head, and the CONTENT of the post: the key is `status:<head>:<sha256(state\ndescription)[:16]>`, so a later verdict on the same head is a different effect rather than a replay of the earlier one. Posted for EVERY verdict (spec §4), best effort -- a failure here does not change the derived outcome |
| `coordinator/outcome.py` `derive` | `gh pr comment <pr> --repo <repo> --body <derived summary>` | `comment` | the body's own digest: `pr-comment:<pr>:<sha256(body)[:16]>`. Best effort, for the same reason |
10 changes: 5 additions & 5 deletions docs/design/scar-effect-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,10 @@ v1's own `--self-test` catches the mutation: where it does not, the v2 test is
| v1 behaviour | source | mutation that breaks it | v2 owner | test fixture | fault injected | expected durable events | effects |
|---|---|---|---|---|---|---|---|
| CI failure classification | `run-queue.sh:270` `_classify_ci_failure` `echo genuine` | `-gt 0` → `-ge 0`; or always `infra` — **v1 binds, both directions** | not yet ported — M1-5 | v1 `--self-test` | 0 and 3 failed steps | `external_observation` | none |
| Merge gate fail-closed | `run-queue.sh:3784` `_merge_gate` `printf 'skip'` | drop the empty-head `skip` branch — **v1 binds** | kernel `request_merge` head binding | v1 `--self-test` | no head supplied | `command_rejected` | none |
| Merge-authorizing status publication | `run-queue.sh:1196` `_post_cross_review_status` `_effect status_check` | write the `gh api` call directly instead of through `_effect` — **v2 binds** | kernel effect journal, class `status_check` | `test_routing.py` | an unrouted call planted in the source | `effect_intended`, `effect_confirmed` | `status_check` |
| Merge orchestration | `run-queue.sh:1660` `merge_ready_pr` `_effect merge` | route as `pull_request` instead of `merge` — **v2 binds** | `perform` + `revalidate_merge` | `test_merge_revalidation.py` | reviewed artifact deleted after authorization | `command_rejected`, no `effect_intended` | `merge` |
| Recovery push is bounded | `run-queue.sh:1866` `merge_ready_pr` `revert-push:$pr` | cap → `-`; or `_net_run` drops `-k` — **v1 binds both** | effect adapter cap → `_net_run` | v1 `--self-test` #62 | the bound removed | `effect_uncertain` on timeout | `ref_update` |
| Merge gate fail-closed | `run-queue.sh:3731` `_merge_gate` `printf 'skip'` | drop the empty-head `skip` branch — **v1 binds** | kernel `request_merge` head binding | v1 `--self-test` | no head supplied | `command_rejected` | none |
| Merge-authorizing status publication | `run-queue.sh:1208` `_post_cross_review_status` `_effect status_check` | write the `gh api` call directly instead of through `_effect` — **v2 binds** | kernel effect journal, class `status_check` | `test_routing.py` | an unrouted call planted in the source | `effect_intended`, `effect_confirmed` | `status_check` |
| Merge orchestration | `run-queue.sh:1643` `merge_ready_pr` `_effect merge` | route as `pull_request` instead of `merge` — **v2 binds** | `perform` + `revalidate_merge` | `test_merge_revalidation.py` | reviewed artifact deleted after authorization | `command_rejected`, no `effect_intended` | `merge` |
| Recovery push is bounded | `run-queue.sh:1849` `merge_ready_pr` `revert-push:$pr` | cap → `-`; or `_net_run` drops `-k` — **v1 binds both** | effect adapter cap → `_net_run` | v1 `--self-test` #62 | the bound removed | `effect_uncertain` on timeout | `ref_update` |
| Reopening reverted issues is bounded | `run-queue.sh:298` `_reopen_reverted_issues` `_effect issue_or_label` | cap → `-` — **v1 binds** | effect adapter cap → `_net_run` | v1 `--self-test` #62 | the bound removed | `effect_intended` | `issue_or_label` |
| The adapter is actually wired in | `run-queue.sh:20` `«top-level»` `effect-adapter.sh` | remove the `source` line — **v1 binds, via #50** | `batch/lib/effect-adapter.sh` | v1 `--self-test` | adapter not sourced | none — the run cannot start | none |
| Effect denial fails closed | `effect-adapter.sh:84` `_effect` `BIRCHER_EFFECT_MODE:-deny` | default mode `deny` → `legacy` — **v2 binds** | `batch/lib/effect-adapter.sh` | `test_fault_injection.py` | `BIRCHER_EFFECT_MODE` unset | none — refused before execution | none |
Expand All @@ -40,7 +40,7 @@ v1's own `--self-test` catches the mutation: where it does not, the v2 test is

| v1 behaviour | source | disposition |
|---|---|---|
| PR recovery checkout chaining | `run-queue.sh:2201` `recover_pr_cmd` `recover_pr_cmd()` | **Unprobed.** The plan proposed joining commands with `;` instead of `&&` as the breaking mutation. Not executed this session, so no claim is made about whether v1 binds it. Carried to M1-5. |
| PR recovery checkout chaining | `run-queue.sh:2184` `recover_pr_cmd` `recover_pr_cmd()` | **Unprobed.** The plan proposed joining commands with `;` instead of `&&` as the breaking mutation. Not executed this session, so no claim is made about whether v1 binds it. Carried to M1-5. |

## Retired — the code they described no longer exists

Expand Down
Loading
Loading