Write lane script with pinned merge
Epic: Merge lane — CP-scheduled verification queue, plan-gated. Planner Task 5517; the full PRD is BA Task 5515 and the full architecture is Architect Task 5516. Read architecture §5, L2, L8, L11, L12 and §4.7 in full with `task_read`.
Builds against C3a's committed wire fixture `apps/api/tests/fixtures/merge_lane_wire.json` (the `lane.batch` / `lane.event` contract).
Principle (verbatim): "develop only moves to a tree the full suite passed."
## Requirement (work item 5, verbatim)
"Lane: `tools/merge_lane.py`, a deterministic script (no model) dispatched to a verification slot through lease-drain, event-driven (a queued PR wakes it, like `task_wake`); at most k in flight. Fetch develop + queued PR heads (SHAs pinned at queue time), merge onto a temp branch, `./dc check full` once; green → `jaah-forge pr merge --sha <tested head>` in batch order; red → sequential bisect (merge the green first half, test the second half on the new base), post the failing tests on the culprit PR and deliver them to its runner's pane; rerun-once of failed tests on the same tree, pass-on-rerun = flake recorded + flake-fix task filed; after merging, fetch develop and verify its tree hash equals the certified one (mismatch → immediate full run on real develop + alert). Report results to the CP over the vocabulary socket; CP records the certificate (tree hash → green)."
## Approach (settled)
`tools/merge_lane.py` is stdlib only. It shells out to `git`, `jaah-forge` and `jaah-call`, and runs in the slot's worktree.
1. Call `lane.batch`. If there are no rows, settle `outcome` and exit.
2. Fetch `origin/<integration_branch>` and record `base`. For each row, fetch the PR head (GitHub `pull/<n>/head`; per-forge refspec from `pr view`).
- Forge head differs from the pinned SHA → `superseded`.
- Merged or closed → `closed`.
- `policy.check_mergeable` would refuse → `not_ready`.
3. Build `git checkout -B lane/<batch_id> <base>`, then `git merge --no-ff --no-edit <sha>` for each PR in queue order.
- A conflict → `git merge --abort`, report `failed` with `kind: conflict` and the conflicting paths, and drop that PR (L12).
4. Run `gate_full_cmd` once.
- Red → rerun the WHOLE gate once on the same tree (L8). Green on the rerun → a `flake` event per parsed id, and the batch is green.
5. Green path:
- `git ls-remote origin <ib>` must still equal `base`. Otherwise report `requeue` and exit (D9).
- For each PR in order: `jaah-forge pr merge <n> --sha <head>` → `merged`.
- A mid-batch refusal → `requeue` for the unmerged rows, then step 6 on what did merge.
6. Verify: fetch, then require `origin/<ib>^{tree}` == the temp branch tree.
- Equal → `certified`.
- Unequal → `tree_mismatch`, then run the gate on real develop and report `certified` or `develop_red`.
7. Red path, sequential bisect: split into A and B.
- Test A on base. Green → merge A (steps 5–6), and base = the new develop. Then test B on the new base.
- Recurse to a single culprit PR → `failed` with its tests, plus `jaah-forge pr comment <n> --body-file` listing the tests and the excerpt.
- NO `pr merge` for any tree that did not pass.
8. Settle `outcome` with a one-line summary such as "batch 3 PRs: 2 merged, 1 failed".
The parser `parse_failures` is tolerant:
- pytest `^FAILED (\S+)`;
- vitest `^\s*(FAIL|×)\s+(.+)$`;
- Playwright `^\s*✘\s+\d+\s+(.+)$`;
- `GATE_RESULT ... failed=`;
- otherwise the literal `gate`.
The excerpt is the last 150 lines, redacted with the existing `transcript_redact` patterns.
Forge pin (§4.7), in `apps/jaahd/forge_cli`: `pr merge N [--sha S]`.
- If `view`'s `head_sha != S`, exit 2 ("head moved") BEFORE any write.
- Otherwise send S as the pin on GitHub (`adapters/github.py:275`), GitLab (`gitlab.py:147`) and Azure (`azure.py:166`).
- Bitbucket refuses `--sha` with exit 5.
- `check_mergeable` is unchanged.
Packaging (L2): add `tools/merge_lane.py` to the jaah package's tool distribution (`tools/portable_standard.py` / packaging) so jaahd's installed tools dir serves it. C3b's `ScriptAdapter` resolves it there.
## Acceptance (verbatim)
- AC5.2 Batch run:
1. Fetch develop and the queued PRs' heads, **at the SHAs pinned in their rows**. A PR whose forge head no longer equals its pinned SHA is skipped and marked `superseded`.
2. Merge them, in queue order, onto a temp branch based on develop.
3. Run the project's `gate_full_cmd` **once**.
- AC5.3 Green path (script half):
- Before merging, the lane re-fetches develop. If develop moved from the tested base, it does **not** merge; it re-batches on the new base (D9).
- Otherwise it runs `jaah-forge pr merge --sha <tested head>` for each PR in batch order.
- It then fetches develop and asserts `git rev-parse develop^{tree}` equals the certified tree hash.
- A mismatch triggers an immediate full run on real develop and an operator alert (D10).
- AC5.4 Red path, with sequential bisect:
- Split the batch. Test the first half on the base. If it is green, merge it, and then test the second half on the **new** base. Recurse until each red half is one PR.
- That PR is the culprit. It is marked `failed`, and its failing test ids and their output excerpts are **posted on the PR** (via `jaah-forge`, by the lane node).
- Non-culprit PRs in the batch still merge, each on a tested tree.
- **No PR is ever merged on a tree that did not pass.**
- AC5.5 Rerun-once (script half): a failing test is rerun once on the same tree. If it passes on the rerun, it is a **flake** (reported with test id and tree hash) and the batch counts as green. If the test fails twice, it is a real failure (AC5.4).
- AC5.7 **Integration oracle (Done-means):**
- Setup: a fixture repo + fake forge, two concurrent PRs: PR-A green, and PR-B breaking one test.
- Expected: PR-A merges. PR-B is `failed` with the failing test listed on the PR and delivered. Develop's history never contains a commit whose tree includes PR-B's change. `develop^{tree}` after the run equals the certificate's tree hash.
- Flake variant: a test that fails on its first run and passes on the rerun ends with both PRs merged and one flake row recorded.
Credentials & reach: none. The fixture-integration tests use local git fixture repos, a fake `jaah-forge` and a fake `jaah-call` (verified; worktree-local).
Out of scope:
- Epic (verbatim): "GitHub merge queue / merge_group; any CP-side forge calls; changing the manual develop → master promotion; cgroup tuning of GHA runners; squash merges."
- BA-derived (verbatim): "No bypass of forge protections: the lane does not merge a PR whose forge-required checks or reviews are unmet, and it uses no admin/bypass merge."
- Do NOT:
- use squash or rebase merges;
- change `check_mergeable`;
- add Bitbucket lane support (deferred);
- add per-test reruns;
- add CP routes or rows (C3a);
- change dispatch or `ScriptAdapter` (C3b);
- edit CI workflows.
Done means:
- AC5.7 — the integration test, using a fixture repo, a fake forge and a fake `jaah-call` that loads C3a's `merge_lane_wire.json`:
- PR-A is merged;
- PR-B gets a `failed` event with the broken test id and a `pr comment` on the fake forge;
- `git log --format=%T` of develop never contains a tree with PR-B's change;
- the final `develop^{tree}` equals the `certified` event's tree;
- the flake variant ends with both merged and exactly one `flake` event.
- AC5.3 — tests:
- develop moved between test and merge gives `requeue` and no `pr merge` call;
- forced tree inequality gives `tree_mismatch`, then a real-develop run, then `certified` or `develop_red`.
- AC5.2 — tests:
- a moved forge head gives `superseded` and is excluded;
- a not-mergeable PR gives `not_ready`;
- the gate is invoked exactly once on an all-green batch.
- L12 — a conflicting PR gives `failed` with `kind: conflict`, and the rest of the batch proceeds.
- AC5.4 — a 4-PR batch with one culprit:
- the 3 innocents merge, each on a tested tree;
- the fake forge records zero `pr merge` calls for any untested tree.
- `parse_failures` unit fixtures for pytest, vitest, Playwright, `GATE_RESULT` and the fallback.
- §4.7 — `jaah-forge` tests:
- `--sha` mismatch exits 2 with no write;
- a match sends the pin on GitHub, GitLab and Azure;
- Bitbucket exits 5;
- `pr merge` without `--sha` is unchanged.
- L2 — the packaging test or manifest shows `merge_lane.py` in the distributed tools.
Required checks: gate, review-lane
Outcome
Built tools/merge_lane.py (batch, bisect, rerun-once flakes, tree cert, bwrap-fenced gate) + jaah-forge pr merge --sha pin. Full gate green; PR #4829 merged; Jaah Deploy ok. Deferred: GitHub strict up-to-date protection blocks batch PR 2+.
Artifacts
Activity
- Draft
- Ready
- Runner started
- Runner ended (done)