From 385a3e0375faf238155a2a411ddd98aa0914fcca Mon Sep 17 00:00:00 2001 From: Phillip Cunliffe Date: Wed, 19 Aug 2026 16:43:14 -0700 Subject: [PATCH] Bound Neutral work and support merge queues --- .claude/skills/neutral-reconcile/SKILL.md | 95 ++++++++++++++----- llp/0007-config-and-onboarding.spec.md | 4 + llp/0009-maintenance-reconcilers.spec.md | 8 ++ llp/0017-triage-at-review-cap.decision.md | 4 + llp/0019-automerge.decision.md | 4 + llp/0030-own-pr-approved-label.decision.md | 5 + llp/0052-observe-command.decision.md | 4 + ...ission-control-and-merge-queue.decision.md | 89 +++++++++++++++++ src/admission.js | 63 ++++++++++++ src/commands/idle.js | 15 ++- src/commands/observe.js | 9 +- src/commands/prs.js | 18 ++-- src/config.js | 19 ++++ src/github.js | 25 ++++- src/index.js | 5 +- src/prhealth.js | 13 ++- src/types.d.ts | 30 +++++- test/admission.test.js | 64 +++++++++++++ test/config.test.js | 20 ++++ test/github.test.js | 18 +++- test/idle-command.test.js | 18 ++++ test/maintenance-commands.test.js | 30 +++++- test/prhealth.test.js | 15 +++ 23 files changed, 524 insertions(+), 51 deletions(-) create mode 100644 llp/0060-admission-control-and-merge-queue.decision.md create mode 100644 src/admission.js create mode 100644 test/admission.test.js diff --git a/.claude/skills/neutral-reconcile/SKILL.md b/.claude/skills/neutral-reconcile/SKILL.md index 66aaf85..314d882 100644 --- a/.claude/skills/neutral-reconcile/SKILL.md +++ b/.claude/skills/neutral-reconcile/SKILL.md @@ -1,13 +1,13 @@ --- name: neutral-reconcile -description: Run one reconcile tick of neutral — observe git/GitHub ground truth across both reconciler families (LLP→PR pipeline + PR/issue maintenance), fan out every branch-disjoint gap in parallel, fan in serial verified merges, and re-derive "done" from git. Holds every result for a human; never merges unless the repo opts in (`automerge`, LLP 0019). Idempotent and safe to re-run. Use when running `/loop /neutral-reconcile` to drive a repo toward neutral state, or to run one tick by hand. +description: Run one reconcile tick of neutral — observe git/GitHub ground truth across both reconciler families (LLP→PR pipeline + PR/issue maintenance), heal every admitted branch-disjoint gap in parallel, admit bounded new work, fan in serial verified merges, and re-derive "done" from git. Holds every result for a human; lands only when the repo opts in (`automerge`, optionally through `mergeQueue`). Idempotent and safe to re-run. Use when running `/loop /neutral-reconcile` to drive a repo toward neutral state, or to run one tick by hand. allowed-tools: Bash, Read, Write, Edit, Agent, Skill, Workflow --- # neutral-reconcile One **tick** of the neutral reconciler. A tick observes ground truth across every -reconciler family, **fans out all branch-disjoint gaps in parallel**, **fans in** +reconciler family, **fans out admitted branch-disjoint gaps in parallel**, **fans in** serial verified merges, re-derives "done" from git/GitHub, and returns. Re-running is always safe — state is derived, not stored. Driven by `/loop /neutral-reconcile`. @@ -28,8 +28,9 @@ no in-scope PR left unmergeable / failing / unreviewed. Neutral stops at the boundary of what only a human may do: **merging is the one act neutral never performs** — unless the repo owner moved that boundary with `automerge: true` in `.neutral/config.json` (LLP 0019), in which case the terminal rung merges -instead of holding. By default it drives every artifact to *held, green, -reviewed* and waits. +instead of holding. `mergeQueue: true` delegates base freshness and landing order +to GitHub's merge queue (LLP 0060). By default it drives every artifact to *held, +green, reviewed* and waits. ## The one rule — ground truth, never self-report (LLP 0002) @@ -85,16 +86,19 @@ work, the tick verifies it. - `prs` → every in-scope open PR (own `integration/*` and `fix/issue-*`) with the **single rung action** `reconcilePR` should take this tick (`merge-base | resolve-conflict | fix-ci | review | triage | ready-hold | - stuck-report | unstick | wait | held`). The CLI decides the rung from observed + merge | enqueue | stuck-report | unstick | wait | held`). The CLI decides the rung from observed state — you act, you do not re-decide. A non-zero `guidance` field means the thread carries human replies to a stuck report (LLP 0027) — feed them to any worker you dispatch for that PR. - `issues` → every open `neutral:fix` issue with its fix-attempt state (`needs-fix | attempt-exists | stuck`). -3. **Fan out** every **branch-disjoint** gap concurrently (LLP 0010) — implement a - change set, resolve a conflict on PR X, fix CI on PR Y, review PR Z, mint a - design, write the fix for issue I. Each worker is blind to the others and works in - its **own** `git worktree` (never the main checkout). + - `admission` → the deterministic LLP 0060 work-cap decision: active/frozen + surfaces, `available` new slots, and `open`. Trust this field; never recount it. +3. **Fan out admitted work** concurrently (LLP 0010/0060). Every existing PR and + change set continues through its owed action even when admission is paused. New + branch-producing intake is bounded by `admission.available` as described below. + Each worker is blind to the others and works in its **own** `git worktree` + (never the main checkout). 4. **Fan in** — *you*, the orchestrator, perform the serial verified merges and **re-derive "done" from git/`gh`** before anything counts. A worker's report is a hint; the re-derivation is the conclusion. @@ -128,6 +132,34 @@ Workflow concurrency cap is hit, **priority is only queue order** (held-PR dependents → review → implement → issue-fix → design); it no longer selects a single action. +### Admission — heal broadly, start narrowly (LLP 0060) + +`observe.admission` is the one authority for new work. Its active surfaces already +include every non-frozen open PR, unshipped change set, and fix-attempt branch, with +the branch/PR forms deduplicated. `neutral:stuck` work awaiting a human is listed in +`frozen` and frees its slot. + +- **Always advance admitted work:** every `prs` action and every non-null + `changesets` action. In particular, `create-pr` consumes no new slot: its change + set already consumed one. Capacity never stops healing, checking, reviewing, + triaging, or landing work already in flight. +- **New work consumes a slot when its branch is created:** a `needs-fix` issue's + `fix/issue-*` branch, an `implementable` design's `integration/*` branch, each + Designer-minted `integration/*` branch, and any autophagy branch/PR. +- **Reserve at most `admission.available` slots this tick.** Prefer deferred + `needs-fix` issues, then design-first `implementable` entries, then backlog + Designer groups. Within each class use the report's stable order. Decrement the + local reservation when a branch is actually created; a failed/no-op creation + consumes none. Do not refill from a fresh observation mid-tick. +- **At `available: 0`, admit nothing new.** A triage worker still records a + non-blocking finding as a `neutral:fix` issue, but do not dispatch Issue-fix for + it until a later tick exposes a slot. This is how review feedback becomes backlog + instead of recursively becoming more PRs. + +The Designer may partition the whole backlog for reasoning, but mints no more than +the slots reserved for Designer groups. The remainder stays uncovered and is +re-observed next tick. + ### Context autophagy — recycle on idle (LLP 0013) On a genuinely **idle** tick (neutral reached, nothing in flight) whose measured @@ -261,7 +293,8 @@ tier per their heading. ## Fan-out worker: Designer (pipeline) — judgment tier (`fable`) Goal: every live request is `@ref`'d by a `design` LLP. Plan the **whole** backlog -up front and mint **all** change sets in one pass (do not dribble one group per tick). +up front, then mint only the groups covered by this tick's reserved admission slots +(LLP 0060). The remainder deliberately stays in the backlog for a later tick. 1. `neutral backlog --json` — the full backlog (already excludes code-, in-flight-, and baseline-covered requests). Empty → no Designer work. @@ -271,8 +304,9 @@ up front and mint **all** change sets in one pass (do not dribble one group per `Systems:`, dense `Related:`, a natural feature boundary); order with `dependsOn` so B follows A when B builds on A's code; keep groups independent where you can. `log` the plan (one line per group). -3. **Mint every change set** in topological order, sequential LLP numbers across the - batch (start at one past the highest LLP number across `` and all +3. **Mint the admitted change sets** in topological order, stopping at the reserved + Designer slot count. Use sequential LLP numbers across the admitted batch (start + at one past the highest LLP number across `` and all `integration/*`; `git ls-tree -r --name-only llp/`). For each, in its **own detached worktree** (never the main checkout, LLP 0012): - `WT=$(mktemp -d) && git worktree add --detach "$WT" origin/ && cd "$WT"` @@ -291,7 +325,8 @@ branch. A design is implementable two ways: **neutral-minted** (already on `integration/` from the Designer), or **design-first** (LLP 0016) — a human merged a `design` to the target at `**Status:** Accepted`, surfaced by `neutral implementable`. -**Design-first only — seed the branch first** (idempotent; skip if it exists): in a +**Design-first only — seed the branch first** only when this entry received one of +the tick's admission slots (idempotent; skip if it exists): in a detached worktree off the target, create `integration/` so the change set has a branch (the design rides along from the target) — `WT=$(mktemp -d) && git worktree add --detach "$WT" origin/ && cd "$WT" && git push origin HEAD:integration/ && cd && git worktree remove --force "$WT"`. @@ -371,7 +406,7 @@ PR each tick, sync the `neutral:approved` label to the decision's **`approved`** `gh pr edit N --add-label neutral:approved` iff `approved` is `true` and the label is absent, or `gh pr edit N --remove-label neutral:approved` iff `approved` is falsy and the label is present (do nothing when already in sync). `approved` is `true` only at the -reviewed-clean terminal (`ready-hold` / `held` / `merge`), so the label is added there and +reviewed-clean terminal (`ready-hold` / `held` / `merge` / `enqueue`, plus queue `wait`), so the label is added there and **stripped the instant the PR regresses** (any heal/review/stuck/triage rung omits the field) — it tracks the current reviewed-clean head and never goes stale. This runs alongside the rung action below; it is **not** itself a rung and never blocks one. Adopted @@ -433,7 +468,8 @@ which replies are new. **draft** PR `integration/ → DEFAULT` (`gh pr list --head …` else `gh pr create --draft --base DEFAULT --head …`), body ending `Change-Set: `. A `fix/issue-*` PR is created by the issue-fix worker (below) with `Fixes #N`. -- **`merge-base`** (rung 1, `BEHIND` — stale, no conflict): **mechanical, no agent**, +- **`merge-base`** (rung 1, `BEHIND` — stale, no conflict; emitted only when + `mergeQueue` is off): **mechanical, no agent**, in a **detached worktree** (never the main checkout, LLP 0012) — `` is `integration/` or the `fix/issue-*` branch: `WT=$(mktemp -d) && git worktree add --detach "$WT" origin/ && cd "$WT" && git merge --no-edit origin/ && git push origin HEAD:`, @@ -495,7 +531,8 @@ which replies are new. Comment on the PR linking the issue. Then append `` to the PR body (`gh pr edit N --body …`) — **last**, so a partial failure re-triages rather than skipping. The marker satisfies the reviewed rung; **next tick** the PR is - terminal → `ready-hold` flips it `gh pr ready` and HOLDS for a human to merge. The + terminal → `ready-hold` holds it, or `enqueue` submits it where automatic queue + landing is configured. The deferred findings ride the issue-fix reconciler (the invariants compose — LLP 0008). - **Any residual finding is a true blocker** → it cannot merge safely. Label the PR `neutral:stuck` and post the **stuck report** (LLP 0026, format above): why each @@ -527,6 +564,15 @@ which replies are new. refused (branch protection), leave it — next tick re-observes. **Verify like a human merge:** next tick the design LLP on `origin/` / `gh pr view --json state` = `MERGED` is the ground truth, not gh's exit code. +- **`enqueue`** (terminal, only when both `automerge: true` and `mergeQueue: true` + — LLP 0060): `gh pr ready ` if still a draft, then + `gh pr merge --squash --match-head-commit `. The head guard prevents + a changed, unreviewed head from entering between observation and action. On a + queue-required target this adds the PR to GitHub's queue; it does not bypass it. + Next tick, a live GraphQL `mergeQueueEntry` makes the CLI return `wait` with + `approved: true`. Do not merge the target into the branch, re-review, or re-enqueue + while that entry exists. If GitHub removes the entry, the CLI re-opens the proper + rung from current ground truth. - **`wait`** / **`held`**: do nothing this tick. ### Delegated PRs — `neutral:adopt` / `neutral:review` (LLP 0025/0032/0058) @@ -541,8 +587,8 @@ the verdict, but **never push to the branch**, even when push access exists (LLP delegation is tagged `[adopt]` and rides the ordinary own-PR ladder above **end-to-end**: heal every rung and **push the fixes to the contributor's branch**, `triage` at the review cap (LLP 0017), sync `neutral:approved` to the decision's `approved` field (LLP 0030), and -take the own terminal — `ready-hold`/`held`, or `merge` where the repo opted into automerge -(LLP 0019). The maintainer's label delegated the PR's *whole care, terminal included* +take the own terminal — `ready-hold`/`held`, `merge`, or `enqueue` where the repo opted +into automatic landing (LLP 0019/0060). The maintainer's label delegated the PR's *whole care, terminal included* (LLP 0024/0058); there is no additional consent to seek and no reason to hold back because the code started as a contributor's. Do not voluntarily downgrade to review-only, do not substitute a comment for a fix you could push, and do not skip the PR out of caution: a @@ -592,7 +638,8 @@ documented `neutral:stuck`. The reconciler's whole job is **issue → fix PR**; `reconcilePR` then carries that PR to held + green + reviewed (the two invariants compose). The label is the **authorization** — no `neutral:fix`, no action. -For each issue `neutral issues --json` reports as **`needs-fix`** (skip +For each issue `neutral issues --json` reports as **`needs-fix`** that received one +of this tick's admission slots (skip the rest until capacity opens; skip `attempt-exists` — resume via `reconcilePR`; skip `stuck` — a human must look): 1. **Idempotent intake** (the CLI already checked): `fix/issue-N` branch off the @@ -637,16 +684,16 @@ begin. Delete the merged integration branch (local + `git push origin --delete`) waiting on in-terminal input. Every question for a human goes through `neutral:stuck` + the marker-signed stuck report on the artifact's thread (LLP 0026/0027), where it blocks only that one artifact instead of the loop. -- **Never merge — unless the CLI's rung says `merge`.** Merging is the one +- **Never land — unless the CLI's rung says `merge` or `enqueue`.** Landing is the one irreversible act, a human's by default; drive to held + green + reviewed and stop. The single exception is the repo opting in via `automerge: true` - (LLP 0019), and even then only the `neutral prs` action decides — never merge - on your own judgement. + (LLP 0019); `mergeQueue: true` selects queue landing (LLP 0060). Even then only + the `neutral prs` action decides — never merge or enqueue on your own judgement. - **Never push to the target branch.** All design/plan/code/fixes land via a held PR. - **Never `gh pr ready` or merge an unlabelled foreign PR or a review-only delegation.** Own PRs (`integration/*`, `fix/issue-*`) **and adopted PRs** (`[adopt]` — a pushable `neutral:adopt` delegation is neutral's own, LLP 0058) terminate in - `ready-hold`/`held`/`merge` and carry `neutral:approved` at that reviewed-clean terminal, + `ready-hold`/`held`/`merge`/`enqueue` and carry `neutral:approved` at that reviewed-clean terminal, synced head-accurately to the decision's `approved` field (LLP 0030 — added at the terminal, stripped on any regression); a **review-only** foreign PR (`[review]` / `[adopt,review-only]`, LLP 0025/0032) terminates in a **verdict label** @@ -656,6 +703,8 @@ begin. Delete the merged integration branch (local + `git push origin --delete`) (LLP 0024/0058), and skipping it leaves a gap open. In review-only mode neutral only reviews and posts the verdict. - **Branch-disjoint fan-out.** At most one worker per `integration/` / PR per tick. +- **Bounded intake.** Continue every active surface, but create no more new work + branches than `observe.admission.available`; create none when it is zero (LLP 0060). - **Head-SHA keying.** "Green" and "reviewed" only count for the *current* head SHA; re-read it each tick. - **PENDING / UNKNOWN = wait, not act.** A running check or computing mergeability is diff --git a/llp/0007-config-and-onboarding.spec.md b/llp/0007-config-and-onboarding.spec.md index eb8b9eb..2087073 100644 --- a/llp/0007-config-and-onboarding.spec.md +++ b/llp/0007-config-and-onboarding.spec.md @@ -37,6 +37,10 @@ file → defaults. Tracked (not gitignored). Fields: > field (default `false`) — opt-in per repo to let the terminal reconcilePR rung > squash-merge a finished PR instead of holding it for a human. +> **Extended-by [LLP 0060](0060-admission-control-and-merge-queue.decision.md):** +> adds `maxActiveWork` (default `4`) for bounded intake and `mergeQueue` (default +> `false`) for queue-owned freshness and landing. + ## Baseline `.neutral/baseline.json` (tracked) lists request LLPs that already existed / were diff --git a/llp/0009-maintenance-reconcilers.spec.md b/llp/0009-maintenance-reconcilers.spec.md index 6a91e26..21528ca 100644 --- a/llp/0009-maintenance-reconcilers.spec.md +++ b/llp/0009-maintenance-reconcilers.spec.md @@ -101,6 +101,10 @@ per-tick fan-out of LLP 0010.) *precaution* only — CI (the green rung) is the authoritative gate after the push (LLP 0002: the resolving agent does not grade its own merge). - `UNKNOWN` → wait (GitHub still computing). + + > **Extended-by [LLP 0060](0060-admission-control-and-merge-queue.decision.md):** + > in `mergeQueue` mode an own PR's `BEHIND` state is satisfied by the queue's + > merge-group validation, so Neutral does not merge the target into every branch. 2. **Green** — `gh pr view --json statusCheckRollup`, read against the current head SHA: - `FAILURE`/`ERROR` → an agent fixes from the failing logs @@ -140,6 +144,10 @@ what, via the change-set DAG, unblocks dependents (LLP 0003). > instead of `ready-hold`/`held` — flip ready if draft, then squash-merge. The > three rungs and the `neutral:stuck` override are unchanged. +> **Extended-by [LLP 0060](0060-admission-control-and-merge-queue.decision.md):** +> with both `automerge` and `mergeQueue` enabled, the terminal emits `enqueue` and +> then `wait`s while GitHub reports a `mergeQueueEntry`. + **Reuse.** `reconcilePR` is shared with the pipeline family — the Reviewer reconciler (LLP 0000) becomes rung 3 plus the new mergeable/green rungs — so neutral's own change-set PRs gain the CI and mergeability self-healing the pipeline diff --git a/llp/0017-triage-at-review-cap.decision.md b/llp/0017-triage-at-review-cap.decision.md index 6c0c091..8430f6c 100644 --- a/llp/0017-triage-at-review-cap.decision.md +++ b/llp/0017-triage-at-review-cap.decision.md @@ -30,6 +30,10 @@ This decision inserts a **triage** step at the cap. Before parking, neutral re-j they ride the **issue-fix** reconciler, so a deferred finding reaches neutral state only when its `neutral:fix` issue has a fix attempt (the two invariants compose — LLP 0008). +> **Extended-by [LLP 0060](0060-admission-control-and-merge-queue.decision.md):** +> the follow-up issue is still recorded immediately, but its fix attempt waits for +> `maxActiveWork` admission. Review feedback cannot bypass the global work cap. + ## The triage judgment lives in the worker, not the classifier "Could this cause a production defect?" is a judgment, so it stays **out** of the pure rung diff --git a/llp/0019-automerge.decision.md b/llp/0019-automerge.decision.md index 283cba0..11f5181 100644 --- a/llp/0019-automerge.decision.md +++ b/llp/0019-automerge.decision.md @@ -38,6 +38,10 @@ flips a draft ready (`gh pr ready`) and **squash-merges** it (`gh pr merge --squash`), the same squash-only-at-the-final-PR rule as the human merge (LLP 0003). +> **Extended-by [LLP 0060](0060-admission-control-and-merge-queue.decision.md):** +> `mergeQueue: true` changes this landing strategy from direct `merge` to `enqueue`; +> `automerge` remains the explicit authority to take either terminal action. + Everything upstream of the terminal rung is unchanged — automerge **relaxes the hold, never the gates**: diff --git a/llp/0030-own-pr-approved-label.decision.md b/llp/0030-own-pr-approved-label.decision.md index 34775b0..e380567 100644 --- a/llp/0030-own-pr-approved-label.decision.md +++ b/llp/0030-own-pr-approved-label.decision.md @@ -40,6 +40,11 @@ label tracks the current reviewed-clean head and **cannot go stale**: when maste an approved PR goes `BEHIND`, its next-tick action is `merge-base` (no `approved`) and the sync strips the label until the rebased head is re-reviewed clean and returns to `ready-hold`/`held`. +> **Extended-by [LLP 0060](0060-admission-control-and-merge-queue.decision.md):** +> queue mode adds `enqueue` and queue-owned `wait` to the reviewed-clean terminal. +> `BEHIND` alone no longer strips approval because GitHub validates freshness in the +> merge group; a changed head still invalidates the head-keyed review as before. + **No body verdict marker for own PRs.** A foreign PR persists its verdict in a head-keyed `` body marker for idempotency, because neutral cannot push to the fork and must not re-label a settled head. An own PR needs no such diff --git a/llp/0052-observe-command.decision.md b/llp/0052-observe-command.decision.md index f05e7f4..8b8806e 100644 --- a/llp/0052-observe-command.decision.md +++ b/llp/0052-observe-command.decision.md @@ -14,6 +14,10 @@ had **no command at all**. `neutral backlog`, `neutral implementable`, `neutral prs` and `neutral issues` each report their own gap family, but two pipeline gaps were left to skill prose: +> **Extended-by [LLP 0060](0060-admission-control-and-merge-queue.decision.md):** +> the same observe surface now also emits the deterministic active-work admission +> state consumed by the reconciler skill. + - *a change set with a plan but unmerged tasks* — findable only by enumerating `origin/integration/*` by hand and running `neutral ready ` per branch, from a worktree of that branch (the working-tree read fails on the main diff --git a/llp/0060-admission-control-and-merge-queue.decision.md b/llp/0060-admission-control-and-merge-queue.decision.md new file mode 100644 index 0000000..a56c153 --- /dev/null +++ b/llp/0060-admission-control-and-merge-queue.decision.md @@ -0,0 +1,89 @@ +# LLP 0060: Bound active work and delegate freshness to the merge queue + +**Type:** Decision +**Status:** Accepted +**Systems:** Engine, Engineer, Reviewer +**Author:** Phil / Codex +**Date:** 2026-08-19 +**Related:** 0002, 0007, 0008, 0009, 0017, 0019, 0030, 0052 + +## Context + +Neutral previously treated every branch-disjoint gap as permission to start more +work. Review could therefore create a positive-feedback loop: several PRs generated +several reviews, non-blocking findings generated `neutral:fix` issues, and the next +tick generated still more PRs. At the same time, every advance of the target branch +made the remaining own PRs `BEHIND`; the mergeable rung merged the target into all of +them, invalidating head-keyed reviews and checks and creating another round of work. + +GitHub's merge queue can validate a PR against the latest target in a synthesized +merge group. It removes the need to keep every candidate branch freshly merged, but +it does not limit how much work Neutral starts. The two controls are complementary: +bound admission before work fans out, then let the queue serialize landing. + +## Decision + + +### Admission control + +Add `maxActiveWork` to `.neutral/config.json`, default **4**. A non-negative integer +is valid; `0` is an explicit intake stop. One slot is consumed by each distinct, +non-frozen work surface Neutral already owns: + +- an in-scope open PR, including a reviewed-clean PR waiting to land; +- an unshipped `integration/*` change set without its own open PR; +- a `fix/issue-*` attempt branch without its own open PR. + +The observations are deduplicated, so an integration branch and its PR are one +surface, not two. A `neutral:stuck` PR that owes only its report or awaits a human, +or a `neutral:stuck` issue, is **frozen**: it remains visible but consumes no slot. +A human reply makes a stuck PR active again at its `unstick` action. + +`neutral observe` emits the complete `admission` decision (`limit`, `used`, +`available`, `open`, active surfaces and frozen surfaces). The skill acts on it; it +does not recount work. At capacity: + +- every existing change set and PR continues through its owed action; +- triage may record deferred findings as a `neutral:fix` issue, preserving the + finding, but the issue-fix worker does not start a branch until a slot opens; +- no new design/change-set branch, design-first integration branch, issue-fix + branch, or repo-hygiene PR is started. + +When slots are available, a tick may admit at most `available` new surfaces. A +Designer may therefore mint at most that many change sets, not the whole backlog. +Once a branch is minted it appears in the next observation and consumes its slot. +The cap throttles intake only; it is never a reason to stop healing admitted work. + + +### Merge queue + +Add `mergeQueue` to `.neutral/config.json`, default **false**. It is an opt-in +landing strategy for a repository whose target branch is configured to require a +GitHub merge queue. `automerge` remains the authority boundary: + +- `mergeQueue: true`, `automerge: false` keeps the human-held terminal, while the + queue still owns base freshness; +- both true change the own reviewed-clean terminal from `merge` to `enqueue`. + +For own PRs in queue mode, `BEHIND` is not a heal action. Real conflicts (`DIRTY`), +current-head checks, current-head review and `neutral:stuck` retain their existing +precedence. At the reviewed-clean terminal, `enqueue` flips a draft ready and runs +`gh pr merge --squash --match-head-commit `. On a queue-required target, +GitHub adds the exact head to the queue and validates the synthesized merge group. + +Queue membership is read from the Pull Request GraphQL `mergeQueueEntry` field. +While an entry exists, the classifier returns `wait` with `approved: true`; it does +not re-enqueue, merge the target into the branch, or repeat review. If GitHub drops +the entry, the next observation re-derives the ordinary rungs and may enqueue again. +Foreign review-only PRs retain their existing contributor-facing freshness rules. + +## Consequences + +- Review follow-ups become backlog while capacity is full instead of recursively + becoming immediate PRs. +- A long-lived reviewed PR still applies useful backpressure until it lands; a truly + stuck one does not freeze the whole system. +- Queue-enabled repositories stop rewriting every own PR when the target advances. + Merge-group CI, not branch churn, proves integration freshness. +- Repositories without queue configuration preserve the existing `merge-base` and + direct `merge` behavior. `automerge` and `mergeQueue` remain explicit opt-ins. diff --git a/src/admission.js b/src/admission.js new file mode 100644 index 0000000..d898730 --- /dev/null +++ b/src/admission.js @@ -0,0 +1,63 @@ +// @ts-check +// Pure admission control over the tick's existing observations. It limits new work +// without interrupting maintenance of work already in flight. +// @ref LLP 0060#admission-control [implements] + +/** @import { AdmissionState, AdmissionSurface, ChangeSetState, IssueFixState } from './types.d.ts' */ + +/** + * Count the work surfaces Neutral already owns. An open PR replaces its underlying + * integration/fix branch as the one visible surface, so the sets are deduplicated. + * A genuinely held `neutral:stuck` artifact is frozen: it remains visible but does + * not consume capacity. A replied-to stuck PR (`unstick`) is active again. + * + * @param {{ changesets?: ChangeSetState[], prs?: Array<{number: number, head: string, action: string, stuck?: boolean}>, issues?: IssueFixState[] }} obs + * @param {number} limit + * @returns {AdmissionState} + */ +export function admissionState({ changesets = [], prs = [], issues = [] } = {}, limit = 4) { + /** @type {AdmissionSurface[]} */ + const active = [] + /** @type {AdmissionSurface[]} */ + const frozen = [] + const openHeads = new Set() + + for (const p of prs) { + if (p.action === 'mark-adopted') continue + openHeads.add(p.head) + const surface = { kind: /** @type {'pr'} */ ('pr'), target: `pr#${p.number}`, reason: p.head } + if (p.stuck && (p.action === 'held' || p.action === 'stuck-report')) frozen.push(surface) + else active.push(surface) + } + + for (const c of changesets) { + if (c.shipped || openHeads.has(c.integration)) continue + active.push({ kind: 'changeset', target: `changeset/${c.slug}`, reason: c.integration }) + } + + for (const i of issues) { + if (i.state === 'stuck') { + frozen.push({ kind: 'issue', target: `issue#${i.number}`, reason: i.via || 'stuck' }) + continue + } + if (i.state !== 'attempt-exists' || !i.via) continue + if (i.via.startsWith('pr:#')) continue + const branch = i.via.startsWith('branch:') ? i.via.slice('branch:'.length) : '' + if (branch && openHeads.has(branch)) continue + active.push({ kind: 'issue', target: `issue#${i.number}`, reason: i.via }) + } + + const safeLimit = Number.isInteger(limit) && limit >= 0 ? limit : 4 + const available = Math.max(0, safeLimit - active.length) + return { + limit: safeLimit, + used: active.length, + available, + open: available > 0, + active, + frozen, + reason: available > 0 + ? `${available} new work slot(s) available` + : `at capacity (${active.length}/${safeLimit}) — reconcile existing work; admit no new branch or PR` + } +} diff --git a/src/commands/idle.js b/src/commands/idle.js index 58a9ec5..666ee9f 100644 --- a/src/commands/idle.js +++ b/src/commands/idle.js @@ -18,6 +18,7 @@ import { collectChangeSets } from '../changesets.js' import { collectPRs } from './prs.js' import { collectIssues } from './issues.js' import { idleState } from '../idle.js' +import { admissionState } from '../admission.js' import { selectInitiative } from '../autophagy.js' import { listDisposedAutophagyPRs } from '../github.js' import { readContextSize } from '../context.js' @@ -35,11 +36,11 @@ import { readContextSize } from '../context.js' * @param {typeof run} [exec] * @param {() => (number|null)} [readCtx] * @param {{ now?: number, damped?: string[] }} [opts] - * @returns {Promise<{ idle: boolean, recycle: boolean, initiative: string|null, contextSize: number|null, threshold: number, blockers: import('../types.d.ts').IdleBlocker[], members: import('../types.d.ts').MemberState[] }>} + * @returns {Promise<{ idle: boolean, recycle: boolean, initiative: string|null, contextSize: number|null, threshold: number, admission: import('../types.d.ts').AdmissionState, blockers: import('../types.d.ts').IdleBlocker[], members: import('../types.d.ts').MemberState[] }>} */ export async function collectIdle(repo, exec = run, readCtx = readContextSize, { now = Date.now(), damped = [] } = {}) { const config = loadConfig(repo) - const { contextRecycleThreshold: threshold } = config + const { contextRecycleThreshold: threshold, maxActiveWork } = config const [{ backlog }, implementable, prs, issues, disposed] = await Promise.all([ collectBacklog(repo), collectImplementable(repo, exec), @@ -51,13 +52,17 @@ export async function collectIdle(repo, exec = run, readCtx = readContextSize, { // @ref LLP 0052#idle-extension [implements] — the idle predicate sees change-set gaps const changesets = await collectChangeSets(repo, exec, { openHeads: new Set(prs.map(p => p.head)) }) const { idle, blockers } = idleState({ backlog, implementable, changesets, prs, issues }) + const admission = admissionState({ changesets, prs, issues }, maxActiveWork) const contextSize = readCtx() const recycle = idle && contextSize !== null && contextSize > threshold const { initiative: member, members } = selectInitiative({ openPRs: prs, disposed, config, now, damped }) // @ref LLP 0035#priority [implements] — recycle preempts repo hygiene // @ref LLP 0047#selection [implements] — else the LRR member, else null - const initiative = recycle ? 'recycle' : (idle ? member : null) - return { idle, recycle, initiative, contextSize, threshold, blockers, members } + // Repo-hygiene creates a new work surface, so the same admission gate applies. + // Context recycle creates no repo work and may still run while intake is paused. + // @ref LLP 0060#admission-control [implements] + const initiative = recycle ? 'recycle' : (idle && admission.open ? member : null) + return { idle, recycle, initiative, contextSize, threshold, admission, blockers, members } } /** @@ -77,7 +82,7 @@ export async function idleCommand(repo, args, exec = run, readCtx = readContextS process.stdout.write(JSON.stringify(s, null, 2) + '\n') } else { const ctx = s.contextSize === null ? 'unmeasured' : `${s.contextSize} tok` - process.stdout.write(`idle=${s.idle} initiative=${s.initiative ?? 'none'} context=${ctx} (T=${s.threshold})\n`) + process.stdout.write(`idle=${s.idle} initiative=${s.initiative ?? 'none'} context=${ctx} (T=${s.threshold}) admission=${s.admission.used}/${s.admission.limit}\n`) if (!s.idle) { for (const b of s.blockers) process.stdout.write(` blocker: ${b.family} ${b.target} — ${b.reason}\n`) } else if (s.initiative !== 'recycle') { diff --git a/src/commands/observe.js b/src/commands/observe.js index 80f66be..8d6cc0c 100644 --- a/src/commands/observe.js +++ b/src/commands/observe.js @@ -11,6 +11,8 @@ import { collectChangeSets } from '../changesets.js' import { collectPRs } from './prs.js' import { collectIssues } from './issues.js' import { idleState } from '../idle.js' +import { admissionState } from '../admission.js' +import { loadConfig } from '../config.js' import { padStart } from '../format.js' /** @import { ChangeSetState, IdleBlocker, IssueFixState, Llp } from '../types.d.ts' */ @@ -21,9 +23,10 @@ import { padStart } from '../format.js' * report is offline-safe like its parts. * @param {string} repo * @param {typeof run} [exec] - * @returns {Promise<{ backlog: Llp[], implementable: Array<{number: number, slug: string, title: string}>, changesets: ChangeSetState[], prs: Awaited>, issues: IssueFixState[], neutral: boolean, gaps: IdleBlocker[] }>} + * @returns {Promise<{ backlog: Llp[], implementable: Array<{number: number, slug: string, title: string}>, changesets: ChangeSetState[], prs: Awaited>, issues: IssueFixState[], admission: import('../types.d.ts').AdmissionState, neutral: boolean, gaps: IdleBlocker[] }>} */ export async function collectObserve(repo, exec = run) { + const { maxActiveWork } = loadConfig(repo) const [{ backlog }, implementable, prs, issues] = await Promise.all([ collectBacklog(repo), collectImplementable(repo, exec), @@ -32,7 +35,8 @@ export async function collectObserve(repo, exec = run) { ]) const changesets = await collectChangeSets(repo, exec, { openHeads: new Set(prs.map(p => p.head)) }) const { idle, blockers } = idleState({ backlog, implementable, changesets, prs, issues }) - return { backlog, implementable, changesets, prs, issues, neutral: idle, gaps: blockers } + const admission = admissionState({ changesets, prs, issues }, maxActiveWork) + return { backlog, implementable, changesets, prs, issues, admission, neutral: idle, gaps: blockers } } /** @@ -59,6 +63,7 @@ export async function observeCommand(repo, args, exec = run) { const none = ' (none)' process.stdout.write( (o.neutral ? 'observe: neutral state — no gaps\n' : `observe: ${o.gaps.length} gap(s)\n`) + + `admission: ${o.admission.used}/${o.admission.limit} active, ${o.admission.available} available — ${o.admission.open ? 'OPEN' : 'PAUSED'}\n` + 'pipeline\n' + ' backlog — requests needing a design:\n' + (o.backlog.length ? o.backlog.map(l => ` ${padStart(String(l.number), 4, '0')} ${l.title} [${l.type}]`).join('\n') : none) + '\n' + diff --git a/src/commands/prs.js b/src/commands/prs.js index e9844db..8d2b753 100644 --- a/src/commands/prs.js +++ b/src/commands/prs.js @@ -8,9 +8,9 @@ // prose, so it is unit-tested rather than an agent's judgement. // @ref LLP 0009#pr-health-reconciler [implements] import { run } from '../git.js' -import { listOpenPRs, listMergedAdoptPRs, viewPR } from '../github.js' +import { listOpenPRs, listMergedAdoptPRs, viewPR, isPRQueued } from '../github.js' import { selectRung, humanRepliesAfterStuckReport, needsAdoptedLabel } from '../prhealth.js' -import { loadConfig, ADOPT_LABEL, ADOPTED_LABEL, REVIEW_LABEL } from '../config.js' +import { loadConfig, ADOPT_LABEL, ADOPTED_LABEL, REVIEW_LABEL, STUCK_LABEL } from '../config.js' import { AUTOPHAGY_PREFIX } from '../autophagy.js' // In scope: neutral's OWN integration/fix PRs (by ownership, no label), PLUS PRs a @@ -34,15 +34,15 @@ const OWN_HEAD_RE = /^(integration\/|fix\/issue-|autophagy\/)/ * not completion, is what the label records. * @param {string} repo * @param {typeof run} [exec] - * @returns {Promise>} + * @returns {Promise>} */ export async function collectPRs(repo, exec = run) { - const { maxReviewRounds, automerge } = loadConfig(repo) + const { maxReviewRounds, automerge, mergeQueue } = loadConfig(repo) const open = await listOpenPRs(repo, exec) // Own by head-branch ownership; delegated only when a maintainer explicitly labelled it — // `neutral:adopt` for full heal (LLP 0025) or `neutral:review` for review-only (LLP 0032). const inScope = open.filter(p => OWN_HEAD_RE.test(p.headRefName) || p.labels.includes(ADOPT_LABEL) || p.labels.includes(REVIEW_LABEL)) - /** @type {Array<{number: number, head: string, base: string, isDraft: boolean, headSha: string, foreign: boolean, reviewOnly: boolean, adopted: boolean, canPush: boolean, guidance: number, markAdopted: boolean, rung: string, action: string, reason: string}>} */ + /** @type {Array<{number: number, head: string, base: string, isDraft: boolean, headSha: string, foreign: boolean, reviewOnly: boolean, adopted: boolean, canPush: boolean, stuck: boolean, queued: boolean, guidance: number, markAdopted: boolean, rung: string, action: string, reason: string, approved?: boolean}>} */ const out = [] for (const p of inScope) { const obs = await viewPR(repo, p.number, exec) @@ -63,14 +63,16 @@ export async function collectPRs(repo, exec = run) { // autophagy cleanup PR is held for a human even in an automerge repo. // @ref LLP 0036#no-automerge [implements] const merge = automerge && !obs.head.startsWith(AUTOPHAGY_PREFIX) - const decision = selectRung({ ...obs, foreign, reviewOnly }, maxReviewRounds, merge) + const queued = mergeQueue && !foreign ? await isPRQueued(repo, obs.nodeId || '', exec) : false + const decision = selectRung({ ...obs, foreign, reviewOnly, queued }, maxReviewRounds, merge, mergeQueue) + const stuck = obs.labels.includes(STUCK_LABEL) const guidance = humanRepliesAfterStuckReport(obs.comments).length // Engagement stamp (LLP 0037): observing an adopt-labelled PR IS taking it on, so // the first tick that classifies it owes it `neutral:adopted`, set-if-absent. // Review-only delegations are narrower and are not adoptions (LLP 0032). // @ref LLP 0037#engagement [implements] const markAdopted = !own && needsAdoptedLabel(obs.labels) - out.push({ number: obs.number, head: obs.head, base: obs.base, isDraft: obs.isDraft, headSha: obs.headSha, foreign, reviewOnly, adopted, canPush: obs.canPush !== false, guidance, markAdopted, ...decision }) + out.push({ number: obs.number, head: obs.head, base: obs.base, isDraft: obs.isDraft, headSha: obs.headSha, foreign, reviewOnly, adopted, canPush: obs.canPush !== false, stuck, queued, guidance, markAdopted, ...decision }) } // Backstop sweep (LLP 0031, retimed by LLP 0037): a MERGED adoption neutral never saw // open still owes its `neutral:adopted` record. With engagement-time stamping above this @@ -82,7 +84,7 @@ export async function collectPRs(repo, exec = run) { if (OWN_HEAD_RE.test(p.headRefName) || !needsAdoptedLabel(p.labels)) continue out.push({ number: p.number, head: p.headRefName, base: '', isDraft: false, headSha: '', - foreign: false, reviewOnly: false, adopted: true, canPush: true, guidance: 0, markAdopted: true, rung: 'terminal', action: 'mark-adopted', + foreign: false, reviewOnly: false, adopted: true, canPush: true, stuck: false, queued: false, guidance: 0, markAdopted: true, rung: 'terminal', action: 'mark-adopted', reason: `merged while carrying ${ADOPT_LABEL} — add ${ADOPTED_LABEL}, the adoption completion record (LLP 0031)` }) } diff --git a/src/config.js b/src/config.js index b587bce..c3d6b17 100644 --- a/src/config.js +++ b/src/config.js @@ -55,6 +55,12 @@ export const DEFAULT_CONTEXT_THRESHOLD = 500_000 // @ref LLP 0047#cooldown [implements] — the two disposition-keyed cooldowns export const DEFAULT_COOLDOWN_AFTER_MERGE_HOURS = 24 export const DEFAULT_COOLDOWN_AFTER_REJECT_HOURS = 168 +// Admission control (LLP 0060): cap Neutral's non-frozen work surfaces so review +// feedback cannot recursively fan out more PRs than the maintainer can absorb. +// Four is the middle of the recommended 3-5 range; repos can tune it, including 0 +// as a temporary intake stop. Existing work continues to reconcile at the cap. +// @ref LLP 0060#admission-control [implements] +export const DEFAULT_MAX_ACTIVE_WORK = 4 /** @type {NeutralConfig} */ export const DEFAULT_CONFIG = { @@ -91,6 +97,15 @@ export const DEFAULT_CONFIG = { // owner moves it here, in a tracked, reviewed file. // @ref LLP 0019 [implements] — automerge relaxes the hold, never the gates automerge: false, + // Opt-in landing strategy for an automerge repo whose target branch requires a + // GitHub merge queue. The queue owns base freshness and the final integration + // check; Neutral stops pushing target merges into every BEHIND branch. + // @ref LLP 0060#merge-queue [implements] + mergeQueue: false, + // Maximum non-frozen work surfaces (open PRs, unshipped change sets and active + // fix branches). At capacity, only new intake pauses; existing surfaces heal. + // @ref LLP 0060#admission-control [implements] + maxActiveWork: DEFAULT_MAX_ACTIVE_WORK, // Context-autophagy trigger threshold T, in tokens (LLP 0013). Per-repo tunable. contextRecycleThreshold: DEFAULT_CONTEXT_THRESHOLD, // Repo-hygiene autophagy members (LLP 0036). On by default — output is a held PR @@ -131,6 +146,10 @@ function merge(base, over) { ? o.maxReviewRounds : base.maxReviewRounds, automerge: typeof o.automerge === 'boolean' ? o.automerge : base.automerge, + mergeQueue: typeof o.mergeQueue === 'boolean' ? o.mergeQueue : base.mergeQueue, + maxActiveWork: Number.isInteger(o.maxActiveWork) && o.maxActiveWork >= 0 + ? o.maxActiveWork + : base.maxActiveWork, contextRecycleThreshold: Number.isInteger(o.contextRecycleThreshold) && o.contextRecycleThreshold > 0 ? o.contextRecycleThreshold : base.contextRecycleThreshold, diff --git a/src/github.js b/src/github.js index db58380..3518277 100644 --- a/src/github.js +++ b/src/github.js @@ -22,7 +22,7 @@ import { AUTOPHAGY_PREFIX } from './autophagy.js' // @ref LLP 0025#push-access-canpush [implements] — isCrossRepository/maintainerCanModify // @ref LLP 0027 [implements] — observe the comment thread the unstick predicate reads // @ref LLP 0028 [implements] — observe the comment thread the review records live in -const PR_VIEW_FIELDS = 'number,headRefName,baseRefName,isDraft,mergeable,mergeStateStatus,statusCheckRollup,headRefOid,body,labels,isCrossRepository,maintainerCanModify,comments' +const PR_VIEW_FIELDS = 'id,number,headRefName,baseRefName,isDraft,mergeable,mergeStateStatus,statusCheckRollup,headRefOid,body,labels,isCrossRepository,maintainerCanModify,comments' /** * Numbers, head branches, and labels of every open PR. Used to enumerate + classify scope @@ -105,6 +105,7 @@ export async function listDisposedAutophagyPRs(repo, exec = run) { */ export function normalizePR(o) { return { + nodeId: o.id || '', number: o.number, head: o.headRefName || '', base: o.baseRefName || '', @@ -132,6 +133,28 @@ export function normalizePR(o) { } } +/** + * Whether GitHub currently has this PR in its base branch's merge queue. The + * mergeQueueEntry field is GraphQL-only (not exposed by `gh pr view --json`), so + * queue-enabled repos make this small second read. Failure is conservative: not + * queued, which lets a later idempotent enqueue retry repair a dropped entry. + * @param {string} repo + * @param {string} nodeId + * @param {typeof run} [exec] + * @returns {Promise} + * @ref LLP 0060#merge-queue [implements] + */ +export async function isPRQueued(repo, nodeId, exec = run) { + if (!nodeId) return false + const query = 'query($id:ID!){node(id:$id){... on PullRequest{mergeQueueEntry{id}}}}' + try { + const out = await exec('gh', ['api', 'graphql', '-f', `query=${query}`, '-F', `id=${nodeId}`], repo) + return !!(JSON.parse(out)?.data?.node?.mergeQueueEntry?.id) + } catch { + return false + } +} + /** * Full health observation for one PR, or null if gh fails. * @param {string} repo diff --git a/src/index.js b/src/index.js index be2f889..38d4499 100644 --- a/src/index.js +++ b/src/index.js @@ -4,7 +4,7 @@ export { readLlps, parseLlp, normalizeStatus, isRequestType, isDesignType, isLive, needsCoverage, isNeutralDesign } from './llp.js' -export { DEFAULT_CONFIG, loadConfig, FIX_LABEL, STUCK_LABEL, DEFAULT_REVIEW_ROUNDS, DEFAULT_CONTEXT_THRESHOLD } from './config.js' +export { DEFAULT_CONFIG, loadConfig, FIX_LABEL, STUCK_LABEL, DEFAULT_REVIEW_ROUNDS, DEFAULT_CONTEXT_THRESHOLD, DEFAULT_MAX_ACTIVE_WORK } from './config.js' export { loadBaseline } from './baseline.js' export { extractRefs, readCodeRefs } from './refs.js' export { coverage } from './coverage.js' @@ -17,7 +17,7 @@ export { } from './git.js' export { observe } from './state.js' export { inFlightCoveredRefs } from './inflight.js' -export { listOpenPRs, viewPR, normalizePR, listLabelledIssues, listOpenPRBodies } from './github.js' +export { listOpenPRs, viewPR, normalizePR, isPRQueued, listLabelledIssues, listOpenPRBodies } from './github.js' export { selectRung, classifyMergeable, rollupConclusion, parseReviewMarkers, reviewRecords, reviewRounds, reviewedAtHead, @@ -25,5 +25,6 @@ export { } from './prhealth.js' export { fixBranchName, fixedIssueNumbers, classifyIssue } from './issuefix.js' export { idleState } from './idle.js' +export { admissionState } from './admission.js' export { contextSizeFromTranscript, usageOf, projectSlug, transcriptPath, readContextSize } from './context.js' export { newestUsageMs, classifySilence, incidentKey, sentinelStep, formatAlert, formatNag, formatRecovery } from './silence.js' diff --git a/src/prhealth.js b/src/prhealth.js index 5ac6549..3eb000b 100644 --- a/src/prhealth.js +++ b/src/prhealth.js @@ -350,10 +350,11 @@ export function rollupConclusion(rollup) { * @param {PrObservation} pr * @param {number} [maxReviewRounds] * @param {boolean} [automerge] opt-in (LLP 0019): terminal = merge, not hold + * @param {boolean} [mergeQueue] opt-in (LLP 0060): queue owns base freshness + landing * @returns {RungDecision} * @ref LLP 0009#pr-health-reconciler [implements] */ -export function selectRung(pr, maxReviewRounds = DEFAULT_REVIEW_ROUNDS, automerge = false) { +export function selectRung(pr, maxReviewRounds = DEFAULT_REVIEW_ROUNDS, automerge = false, mergeQueue = false) { // Held for a human — wins over every rung. neutral sets `neutral:stuck` when it // cannot auto-advance a PR (an unresolved review finding, a design decision it // will not guess at, a conflict it backed off). The label is the authorization @@ -393,11 +394,18 @@ export function selectRung(pr, maxReviewRounds = DEFAULT_REVIEW_ROUNDS, automerg // @ref LLP 0058#foreign-is-review-only [implements] if (pr.foreign) return foreignRung(pr) + // A queue entry is GitHub-owned in-flight work. Keep the approval projection but + // do not heal/re-review/enqueue the same head while the queue validates it. + // @ref LLP 0060#merge-queue [implements] + if (mergeQueue && pr.queued) { + return { rung: 'terminal', action: 'wait', reason: 'enqueued — GitHub is validating the merge group', approved: true } + } + // Rung 1 — mergeable. const m = classifyMergeable(pr.mergeable, pr.mergeStateStatus) if (m === 'wait') return { rung: 'mergeable', action: 'wait', reason: 'mergeability UNKNOWN — GitHub still computing' } if (m === 'resolve-conflict') return { rung: 'mergeable', action: 'resolve-conflict', reason: 'DIRTY — real merge conflict (highest blast radius)' } - if (m === 'merge-base') return { rung: 'mergeable', action: 'merge-base', reason: 'BEHIND — stale base, no conflict; merge target in mechanically' } + if (m === 'merge-base' && !mergeQueue) return { rung: 'mergeable', action: 'merge-base', reason: 'BEHIND — stale base, no conflict; merge target in mechanically' } // Rung 2 — green (keyed to the current head SHA). const g = rollupConclusion(pr.rollup) @@ -433,6 +441,7 @@ export function selectRung(pr, maxReviewRounds = DEFAULT_REVIEW_ROUNDS, automerg // `neutral:approved` label to this field each tick, so the label is added here and stripped // the moment the PR leaves this terminal (any heal/review/stuck rung omits the field). // @ref LLP 0030 [implements] — head-accurate neutral:approved on own PRs + if (automerge && mergeQueue) return { rung: 'terminal', action: 'enqueue', reason: 'mergeable ∧ green ∧ reviewed, merge queue on — flip ready if draft, then enqueue this exact head', approved: true } if (automerge) return { rung: 'terminal', action: 'merge', reason: 'mergeable ∧ green ∧ reviewed, automerge on — flip ready if draft, then squash-merge', approved: true } if (pr.isDraft) return { rung: 'terminal', action: 'ready-hold', reason: 'mergeable ∧ green ∧ reviewed — flip ready, then HOLD', approved: true } return { rung: 'terminal', action: 'held', reason: 'already held for a human — nothing to do', approved: true } diff --git a/src/types.d.ts b/src/types.d.ts index 7f94e1f..5fd8b68 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -103,6 +103,10 @@ export interface NeutralConfig { maxReviewRounds: number /** Opt-in (LLP 0019): terminal rung squash-merges a finished PR instead of holding it. */ automerge: boolean + /** Opt-in (LLP 0060): use GitHub's merge queue for automerge landing and base freshness. */ + mergeQueue: boolean + /** Admission cap over non-frozen PR/change-set/fix work surfaces (LLP 0060). */ + maxActiveWork: number /** Context-autophagy trigger threshold T, in tokens (LLP 0013). */ contextRecycleThreshold: number /** Repo-hygiene autophagy switches + cooldowns (LLP 0036/0047); future members add theirs here. */ @@ -180,6 +184,10 @@ export interface PrObservation { reviewOnly?: boolean /** The comment thread, chronological — carries the marker-signed review records (LLP 0028), the stuck report, the human replies that unstick a held PR (LLP 0026/0027), and the `neutral: rounds +N` review-budget grants (LLP 0059). */ comments: PrComment[] + /** GraphQL node id, used to query the mergeQueueEntry field when queue mode is on. */ + nodeId?: string + /** True when GitHub currently reports a merge queue entry for this PR (LLP 0060). */ + queued?: boolean } /** The single rung action reconcilePR takes on a PR this tick (LLP 0009). */ @@ -187,7 +195,7 @@ export interface RungDecision { /** mergeable | green | reviewed | terminal. */ rung: string /** - * wait | merge-base | resolve-conflict | fix-ci | review | triage | ready-hold | merge | + * wait | merge-base | resolve-conflict | fix-ci | review | triage | ready-hold | merge | enqueue | * stuck-report | unstick | held | approve | request-changes | mark-adopted. * `triage` (review rounds exhausted) is where a blanket `stuck` used to be: the worker * judges the residual findings and either defers non-blockers to a `neutral:fix` follow-up @@ -198,6 +206,8 @@ export interface RungDecision { * or pushed since it (remove the label, ack, re-run the rungs next tick), else `held`. * `merge` is the terminal action only when the repo opted in (`automerge`, LLP 0019): * flip ready if draft, then squash-merge — instead of `ready-hold`/`held`. + * `enqueue` replaces `merge` when `mergeQueue` is also on (LLP 0060); an already + * queued PR returns `wait` with `approved: true` until GitHub lands or removes it. * `approve` / `request-changes` are the terminal + degraded actions for a *review-only* * foreign PR (LLP 0025/0058): they set the `neutral:approved` / `neutral:changes-requested` * verdict labels instead of readying or merging a contributor's PR. `request-changes` also @@ -220,6 +230,24 @@ export interface RungDecision { approved?: boolean } +/** One work surface consuming (or frozen outside) the admission cap (LLP 0060). */ +export interface AdmissionSurface { + kind: 'pr' | 'changeset' | 'issue' + target: string + reason: string +} + +/** Deterministic new-work admission state emitted by `neutral observe` (LLP 0060). */ +export interface AdmissionState { + limit: number + used: number + available: number + open: boolean + active: AdmissionSurface[] + frozen: AdmissionSurface[] + reason: string +} + /** One reason a tick is not idle: a gap still in flight in one of the families (LLP 0013). */ export interface IdleBlocker { /** pipeline | maintenance. */ diff --git a/test/admission.test.js b/test/admission.test.js new file mode 100644 index 0000000..3889e3d --- /dev/null +++ b/test/admission.test.js @@ -0,0 +1,64 @@ +// @ts-check +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { admissionState } from '../src/admission.js' + +test('admission counts each active PR, change set and fix branch once (LLP 0060)', () => { + const s = admissionState({ + prs: [ + { number: 1, head: 'integration/with-pr', action: 'review' }, + { number: 2, head: 'fix/issue-9', action: 'held' } + ], + changesets: [ + changeset('with-pr'), + changeset('without-pr'), + { ...changeset('shipped'), shipped: true } + ], + issues: [ + { number: 9, title: 'covered by PR', state: 'attempt-exists', via: 'branch:fix/issue-9' }, + { number: 10, title: 'branch only', state: 'attempt-exists', via: 'branch:fix/issue-10' }, + { number: 11, title: 'not admitted yet', state: 'needs-fix' } + ] + }, 5) + + assert.equal(s.used, 4) + assert.equal(s.available, 1) + assert.equal(s.open, true) + assert.deepEqual(s.active.map(x => x.target), ['pr#1', 'pr#2', 'changeset/without-pr', 'issue#10']) +}) + +test('held stuck work is frozen outside the cap; replied work becomes active again', () => { + const s = admissionState({ + prs: [ + { number: 1, head: 'integration/frozen', action: 'held', stuck: true }, + { number: 2, head: 'integration/replied', action: 'unstick', stuck: true } + ], + changesets: [changeset('frozen'), changeset('replied')], + issues: [{ number: 7, title: 'human needed', state: 'stuck', via: 'label:neutral:stuck' }] + }, 1) + + assert.equal(s.used, 1) + assert.equal(s.open, false) + assert.deepEqual(s.active.map(x => x.target), ['pr#2']) + assert.deepEqual(s.frozen.map(x => x.target), ['pr#1', 'issue#7']) +}) + +test('zero is a valid hard intake stop', () => { + const s = admissionState({}, 0) + assert.equal(s.used, 0) + assert.equal(s.available, 0) + assert.equal(s.open, false) +}) + +/** @param {string} slug */ +function changeset(slug) { + return { + slug, + integration: `integration/${slug}`, + plan: null, + shipped: false, + action: /** @type {'plan'} */ ('plan'), + reason: 'needs a plan', + ready: [], blocked: [], done: [] + } +} diff --git a/test/config.test.js b/test/config.test.js index acc6c68..750f346 100644 --- a/test/config.test.js +++ b/test/config.test.js @@ -69,6 +69,26 @@ test('loadConfig accepts a boolean automerge, else keeps the default (off)', () } }) +test('loadConfig accepts mergeQueue and a non-negative maxActiveWork (LLP 0060)', () => { + const repo = mkdtempSync(join(tmpdir(), 'neutral-cfg-')) + try { + assert.equal(DEFAULT_CONFIG.mergeQueue, false) + assert.equal(DEFAULT_CONFIG.maxActiveWork, 4) + mkdirSync(join(repo, '.neutral')) + const path = join(repo, '.neutral', 'config.json') + writeFileSync(path, JSON.stringify({ mergeQueue: true, maxActiveWork: 3 })) + assert.equal(loadConfig(repo).mergeQueue, true) + assert.equal(loadConfig(repo).maxActiveWork, 3) + writeFileSync(path, JSON.stringify({ mergeQueue: 'yes', maxActiveWork: -1 })) + assert.equal(loadConfig(repo).mergeQueue, false) + assert.equal(loadConfig(repo).maxActiveWork, 4) + writeFileSync(path, JSON.stringify({ maxActiveWork: 0 })) + assert.equal(loadConfig(repo).maxActiveWork, 0) // explicit intake stop + } finally { + rmSync(repo, { recursive: true, force: true }) + } +}) + test('loadConfig accepts a boolean autophagy.codeCleanup, else keeps the default (on)', () => { const repo = mkdtempSync(join(tmpdir(), 'neutral-cfg-')) try { diff --git a/test/github.test.js b/test/github.test.js index 9b68b9e..8660edf 100644 --- a/test/github.test.js +++ b/test/github.test.js @@ -1,14 +1,14 @@ // @ts-check import { test } from 'node:test' import assert from 'node:assert/strict' -import { listOpenPRs, listMergedAdoptPRs, viewPR, normalizePR, listLabelledIssues, listOpenPRBodies } from '../src/github.js' +import { listOpenPRs, listMergedAdoptPRs, viewPR, normalizePR, isPRQueued, listLabelledIssues, listOpenPRBodies } from '../src/github.js' /** * A fake `gh` runner keyed by subcommand. `fail` makes every call throw (offline). - * @param {{prList?: any, prView?: any, issueList?: any, prBodies?: any, mergedList?: any, fail?: boolean}} cfg + * @param {{prList?: any, prView?: any, issueList?: any, prBodies?: any, mergedList?: any, queueEntry?: any, fail?: boolean}} cfg * @returns {import('../src/git.js').run} */ -function fakeGh({ prList, prView, issueList, prBodies, mergedList, fail = false } = {}) { +function fakeGh({ prList, prView, issueList, prBodies, mergedList, queueEntry, fail = false } = {}) { return async (cmd, args) => { if (fail) { const e = new Error('gh: no remote'); /** @type {any} */ (e).code = 1; throw e } assert.equal(cmd, 'gh') @@ -18,6 +18,7 @@ function fakeGh({ prList, prView, issueList, prBodies, mergedList, fail = false return JSON.stringify(fields.includes('body') ? prBodies : prList) } if (args[0] === 'pr' && args[1] === 'view') return JSON.stringify(prView) + if (args[0] === 'api' && args[1] === 'graphql') return JSON.stringify({ data: { node: { mergeQueueEntry: queueEntry } } }) if (args[0] === 'issue' && args[1] === 'list') return JSON.stringify(issueList) throw new Error('unexpected gh ' + args.join(' ')) } @@ -41,8 +42,8 @@ test('listMergedAdoptPRs filters by state+label via gh, and is empty when gh fai }) test('normalizePR fills stable defaults from a raw gh object', () => { - assert.deepEqual(normalizePR({ number: 3, headRefName: 'integration/y', baseRefName: 'main', isDraft: true, mergeable: 'MERGEABLE', mergeStateStatus: 'CLEAN', statusCheckRollup: [{ state: 'SUCCESS' }], headRefOid: 'deadbeef', body: 'b', labels: [{ name: 'neutral:stuck' }, { name: 'enhancement' }], comments: [{ author: { login: 'phil' }, body: 'looks stuck', createdAt: '2026-07-07T10:00:00Z', url: 'ignored' }] }), { - number: 3, head: 'integration/y', base: 'main', isDraft: true, + assert.deepEqual(normalizePR({ id: 'PR_node', number: 3, headRefName: 'integration/y', baseRefName: 'main', isDraft: true, mergeable: 'MERGEABLE', mergeStateStatus: 'CLEAN', statusCheckRollup: [{ state: 'SUCCESS' }], headRefOid: 'deadbeef', body: 'b', labels: [{ name: 'neutral:stuck' }, { name: 'enhancement' }], comments: [{ author: { login: 'phil' }, body: 'looks stuck', createdAt: '2026-07-07T10:00:00Z', url: 'ignored' }] }), { + nodeId: 'PR_node', number: 3, head: 'integration/y', base: 'main', isDraft: true, mergeable: 'MERGEABLE', mergeStateStatus: 'CLEAN', rollup: [{ state: 'SUCCESS' }], headSha: 'deadbeef', body: 'b', labels: ['neutral:stuck', 'enhancement'], canPush: true, comments: [{ author: 'phil', body: 'looks stuck', createdAt: '2026-07-07T10:00:00Z' }] @@ -69,6 +70,13 @@ test('viewPR observes one PR; null on gh failure', async () => { assert.equal(await viewPR('/r', 5, fakeGh({ fail: true })), null) }) +test('isPRQueued reads the GraphQL-only merge queue entry and degrades false', async () => { + assert.equal(await isPRQueued('/r', 'PR_node', fakeGh({ queueEntry: { id: 'MQE_node' } })), true) + assert.equal(await isPRQueued('/r', 'PR_node', fakeGh({ queueEntry: null })), false) + assert.equal(await isPRQueued('/r', '', fakeGh({ queueEntry: { id: 'MQE_node' } })), false) + assert.equal(await isPRQueued('/r', 'PR_node', fakeGh({ fail: true })), false) +}) + test('listLabelledIssues flattens label objects to names', async () => { const exec = fakeGh({ issueList: [{ number: 7, title: 'crash on save', labels: [{ name: 'neutral:fix' }, { name: 'bug' }] }] }) assert.deepEqual(await listLabelledIssues('/r', 'neutral:fix', exec), [ diff --git a/test/idle-command.test.js b/test/idle-command.test.js index 88240fc..e90f834 100644 --- a/test/idle-command.test.js +++ b/test/idle-command.test.js @@ -132,6 +132,24 @@ test('collectIdle: a held autophagy PR keeps the tick idle but blocks the next c } }) +test('collectIdle: admission capacity blocks a new cleanup initiative (LLP 0060)', async () => { + const repo = mkdtempSync(join(tmpdir(), 'neutral-idle-')) + try { + mkdirSync(join(repo, '.neutral')) + writeFileSync(join(repo, '.neutral', 'config.json'), JSON.stringify({ maxActiveWork: 1 })) + const exec = fakeWorld({ + prs: [{ number: 1, headRefName: 'integration/x' }], + views: { 1: heldView(1, 'integration/x') } + }) + const s = await collectIdle(repo, exec, () => 100_000) + assert.equal(s.idle, true) + assert.equal(s.admission.open, false) + assert.equal(s.initiative, null) + } finally { + rmSync(repo, { recursive: true, force: true }) + } +}) + test('collectIdle: an in-flight autophagy PR blocks idle like any own PR', async () => { const repo = mkdtempSync(join(tmpdir(), 'neutral-idle-')) try { diff --git a/test/maintenance-commands.test.js b/test/maintenance-commands.test.js index 5621c76..0896aa3 100644 --- a/test/maintenance-commands.test.js +++ b/test/maintenance-commands.test.js @@ -10,10 +10,10 @@ import { collectIssues } from '../src/commands/issues.js' /** * A fake runner that answers both `git` (for-each-ref) and `gh` (pr/issue), so the * maintenance observe surface can be exercised fully offline. - * @param {{prs?: any[], views?: Record, issues?: any[], fixBranches?: string[], mergedPrs?: any[]}} cfg + * @param {{prs?: any[], views?: Record, issues?: any[], fixBranches?: string[], mergedPrs?: any[], queuedIds?: string[]}} cfg * @returns {import('../src/git.js').run} */ -function fakeWorld({ prs = [], views = {}, issues = [], fixBranches = [], mergedPrs = [] } = {}) { +function fakeWorld({ prs = [], views = {}, issues = [], fixBranches = [], mergedPrs = [], queuedIds = [] } = {}) { return async (cmd, args) => { if (cmd === 'git' && args[0] === 'for-each-ref') { // only the `fix/*` lookup is exercised here @@ -34,6 +34,10 @@ function fakeWorld({ prs = [], views = {}, issues = [], fixBranches = [], merged if (cmd === 'gh' && args[0] === 'issue' && args[1] === 'list') { return JSON.stringify(issues) } + if (cmd === 'gh' && args[0] === 'api' && args[1] === 'graphql') { + const id = String(args.find(a => String(a).startsWith('id=')) || '').slice(3) + return JSON.stringify({ data: { node: { mergeQueueEntry: queuedIds.includes(id) ? { id: `entry-${id}` } : null } } }) + } throw new Error('unexpected ' + cmd + ' ' + args.join(' ')) } } @@ -97,6 +101,28 @@ test('an adopted PR rides the own ladder to the terminal — automerge merges it } }) +test('collectPRs queue mode ignores BEHIND, enqueues a clean terminal, then waits in queue (LLP 0060)', async () => { + const base = { + id: 'PR_1', number: 1, headRefName: 'integration/auth', baseRefName: 'main', isDraft: false, + mergeable: 'MERGEABLE', mergeStateStatus: 'BEHIND', statusCheckRollup: [], + headRefOid: 'abc1234', body: '' + } + const repo = mkdtempSync(join(tmpdir(), 'neutral-prs-')) + try { + mkdirSync(join(repo, '.neutral')) + writeFileSync(join(repo, '.neutral', 'config.json'), JSON.stringify({ automerge: true, mergeQueue: true })) + const open = fakeWorld({ prs: [{ number: 1, headRefName: 'integration/auth' }], views: { 1: base } }) + assert.equal((await collectPRs(repo, open))[0].action, 'enqueue') + const queued = fakeWorld({ prs: [{ number: 1, headRefName: 'integration/auth' }], views: { 1: base }, queuedIds: ['PR_1'] }) + const [p] = await collectPRs(repo, queued) + assert.equal(p.action, 'wait') + assert.equal(p.queued, true) + assert.equal(p.approved, true) + } finally { + rmSync(repo, { recursive: true, force: true }) + } +}) + test('collectPRs review-only degrades an unpushable fork to request-changes on a stale base (LLP 0025)', async () => { const exec = fakeWorld({ prs: [{ number: 5, headRefName: 'contrib/patch', labels: [{ name: 'neutral:adopt' }] }], diff --git a/test/prhealth.test.js b/test/prhealth.test.js index 3f880f6..e5aac84 100644 --- a/test/prhealth.test.js +++ b/test/prhealth.test.js @@ -245,6 +245,21 @@ test('selectRung terminal with automerge on: merge instead of hold, gates unchan assert.equal(selectRung(pr({ labels: ['neutral:stuck'], body, headSha: 'abc1234', comments: reported }), 2, AUTOMERGE).action, 'held') }) +test('merge queue mode delegates BEHIND freshness and emits enqueue only at terminal (LLP 0060)', () => { + const body = '' + const QUEUE = true + assert.equal(selectRung(pr({ mergeStateStatus: 'BEHIND', body, headSha: 'abc1234' }), 2, true, QUEUE).action, 'enqueue') + assert.equal(selectRung(pr({ mergeStateStatus: 'DIRTY', mergeable: 'CONFLICTING', body }), 2, true, QUEUE).action, 'resolve-conflict') + assert.equal(selectRung(pr({ mergeStateStatus: 'BEHIND', headSha: 'newhead', body }), 2, true, QUEUE).action, 'review') +}) + +test('an enqueued PR waits with approval intact until GitHub lands or removes it', () => { + const d = selectRung(pr({ queued: true, mergeStateStatus: 'BEHIND' }), 2, true, true) + assert.equal(d.action, 'wait') + assert.equal(d.approved, true) + assert.match(d.reason, /merge group/) +}) + // One report comment at the given SHA — the thread baseline for the stuck tests. /** @param {string} sha */ function report(sha) {