diff --git a/.2119/verdicts/REQ-033.9.1--4ea88470081f.json b/.2119/verdicts/REQ-033.9.1--4ea88470081f.json new file mode 100644 index 0000000..a2cb01d --- /dev/null +++ b/.2119/verdicts/REQ-033.9.1--4ea88470081f.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-033.9.1--4ea88470081f", + "requirementId": "REQ-033.9.1", + "hash": "4ea88470081f", + "verdict": "pass", + "summary": "Independent review: the test pins the deferred-issue prefix against a separate literal covering title, PR and comment references, deferral reason, and implementation needs. The suffix permits only the workflow recovery-state substitution; weakening any required issue instruction fails.", + "timestamp": "2026-10-29T07:10:07.612Z" +} diff --git a/.2119/verdicts/merge-recovery-state.1.1--758c872f40db.json b/.2119/verdicts/merge-recovery-state.1.1--758c872f40db.json new file mode 100644 index 0000000..3e5cbc8 --- /dev/null +++ b/.2119/verdicts/merge-recovery-state.1.1--758c872f40db.json @@ -0,0 +1,8 @@ +{ + "reviewId": "merge-recovery-state.1.1--758c872f40db", + "requirementId": "merge-recovery-state.1.1", + "hash": "758c872f40db", + "verdict": "pass", + "summary": "Independent review: The test extracts the rendered repair command in each 2119 workflow and executes a real free move; ITERATING or any non-BUILDING target fails, with fresh pending responsibilities verified.", + "timestamp": "2026-10-29T08:03:58.942Z" +} diff --git a/.2119/verdicts/merge-recovery-state.1.2--817908331560.json b/.2119/verdicts/merge-recovery-state.1.2--817908331560.json new file mode 100644 index 0000000..ac702c1 --- /dev/null +++ b/.2119/verdicts/merge-recovery-state.1.2--817908331560.json @@ -0,0 +1,8 @@ +{ + "reviewId": "merge-recovery-state.1.2--817908331560", + "requirementId": "merge-recovery-state.1.2", + "hash": "817908331560", + "verdict": "pass", + "summary": "Independent review: Real GitHub workflow transitions and the independent full-policy literal reject a BUILDING target or other replacement for ITERATING.", + "timestamp": "2026-10-29T08:04:00.202Z" +} diff --git a/.2119/verdicts/merge-recovery-state.1.3--3c5c1b2643fb.json b/.2119/verdicts/merge-recovery-state.1.3--3c5c1b2643fb.json new file mode 100644 index 0000000..59c825c --- /dev/null +++ b/.2119/verdicts/merge-recovery-state.1.3--3c5c1b2643fb.json @@ -0,0 +1,8 @@ +{ + "reviewId": "merge-recovery-state.1.3--3c5c1b2643fb", + "requirementId": "merge-recovery-state.1.3", + "hash": "3c5c1b2643fb", + "verdict": "pass", + "summary": "Independent review: The complete ordered-policy comparison and single set_state directive guard reject unconditional BLOCKED recovery and added recovery branches during waits.", + "timestamp": "2026-10-29T08:04:01.454Z" +} diff --git a/.2119/verdicts/merge-recovery-state.2.1--5d0176bb1711.json b/.2119/verdicts/merge-recovery-state.2.1--5d0176bb1711.json new file mode 100644 index 0000000..527c60f --- /dev/null +++ b/.2119/verdicts/merge-recovery-state.2.1--5d0176bb1711.json @@ -0,0 +1,8 @@ +{ + "reviewId": "merge-recovery-state.2.1--5d0176bb1711", + "requirementId": "merge-recovery-state.2.1", + "hash": "5d0176bb1711", + "verdict": "pass", + "summary": "Independent review: The full-policy comparison rejects removed evidence fields, stale reads, missing queue lookup, or missing head read-back; the CLI current-head query and mismatch restart establish the intended snapshot.", + "timestamp": "2026-10-29T08:04:02.767Z" +} diff --git a/.2119/verdicts/merge-recovery-state.2.2--39b864ba6c7d.json b/.2119/verdicts/merge-recovery-state.2.2--39b864ba6c7d.json new file mode 100644 index 0000000..4991048 --- /dev/null +++ b/.2119/verdicts/merge-recovery-state.2.2--39b864ba6c7d.json @@ -0,0 +1,8 @@ +{ + "reviewId": "merge-recovery-state.2.2--39b864ba6c7d", + "requirementId": "merge-recovery-state.2.2", + "hash": "39b864ba6c7d", + "verdict": "pass", + "summary": "Independent review: The independently reviewed policy explicitly separates JSON buckets from process exits and forbids pending, optional failures, missing results, and query errors from proving failed required checks; mutations fail the full comparison.", + "timestamp": "2026-10-29T08:04:04.042Z" +} diff --git a/.2119/verdicts/merge-recovery-state.2.3--34950bab1104.json b/.2119/verdicts/merge-recovery-state.2.3--34950bab1104.json new file mode 100644 index 0000000..38c1c7a --- /dev/null +++ b/.2119/verdicts/merge-recovery-state.2.3--34950bab1104.json @@ -0,0 +1,8 @@ +{ + "reviewId": "merge-recovery-state.2.3--34950bab1104", + "requirementId": "merge-recovery-state.2.3", + "hash": "34950bab1104", + "verdict": "pass", + "summary": "Independent review: Exact policy ordering and rejection of the old pre-queue section detect repair branches moved below waits, including known conflicts when check evidence is unavailable.", + "timestamp": "2026-10-29T08:04:05.338Z" +} diff --git a/.2119/verdicts/merge-recovery-state.2.4--ef8d81ec8034.json b/.2119/verdicts/merge-recovery-state.2.4--ef8d81ec8034.json new file mode 100644 index 0000000..89377e2 --- /dev/null +++ b/.2119/verdicts/merge-recovery-state.2.4--ef8d81ec8034.json @@ -0,0 +1,8 @@ +{ + "reviewId": "merge-recovery-state.2.4--ef8d81ec8034", + "requirementId": "merge-recovery-state.2.4", + "hash": "ef8d81ec8034", + "verdict": "pass", + "summary": "Independent review: The pinned empty-required-check clause rejects treating the recognized response as failed, unavailable, or pending and leaves other evidence to decide the action.", + "timestamp": "2026-10-29T08:04:06.532Z" +} diff --git a/.2119/verdicts/merge-recovery-state.3.1--699f500f11c5.json b/.2119/verdicts/merge-recovery-state.3.1--699f500f11c5.json new file mode 100644 index 0000000..3fec9ab --- /dev/null +++ b/.2119/verdicts/merge-recovery-state.3.1--699f500f11c5.json @@ -0,0 +1,8 @@ +{ + "reviewId": "merge-recovery-state.3.1--699f500f11c5", + "requirementId": "merge-recovery-state.3.1", + "hash": "699f500f11c5", + "verdict": "pass", + "summary": "Independent review: The full policy pins MERGING and artifact retention across all non-repair branches; inserting optimistic advancement or implementation recovery fails the comparison.", + "timestamp": "2026-10-29T08:04:07.771Z" +} diff --git a/.2119/verdicts/merge-recovery-state.3.2--55c5b2139115.json b/.2119/verdicts/merge-recovery-state.3.2--55c5b2139115.json new file mode 100644 index 0000000..a0caa4e --- /dev/null +++ b/.2119/verdicts/merge-recovery-state.3.2--55c5b2139115.json @@ -0,0 +1,8 @@ +{ + "reviewId": "merge-recovery-state.3.2--55c5b2139115", + "requirementId": "merge-recovery-state.3.2", + "hash": "55c5b2139115", + "verdict": "pass", + "summary": "Independent review: The ordered unknown-BLOCKED branch must report the unresolved blocker and stop after identified waits are considered; retry loops or recovery substitutions fail.", + "timestamp": "2026-10-29T08:04:08.908Z" +} diff --git a/.2119/verdicts/merge-recovery-state.3.3--fd405999f6d6.json b/.2119/verdicts/merge-recovery-state.3.3--fd405999f6d6.json new file mode 100644 index 0000000..49700c2 --- /dev/null +++ b/.2119/verdicts/merge-recovery-state.3.3--fd405999f6d6.json @@ -0,0 +1,8 @@ +{ + "reviewId": "merge-recovery-state.3.3--fd405999f6d6", + "requirementId": "merge-recovery-state.3.3", + "hash": "fd405999f6d6", + "verdict": "pass", + "summary": "Independent review: The pinned approval branch returns control without a watcher while the non-repair invariant preserves the artifact; watcher or deletion substitutions fail.", + "timestamp": "2026-10-29T08:04:10.091Z" +} diff --git a/.2119/verdicts/merge-recovery-state.3.4--abaa730bb10a.json b/.2119/verdicts/merge-recovery-state.3.4--abaa730bb10a.json new file mode 100644 index 0000000..19c4033 --- /dev/null +++ b/.2119/verdicts/merge-recovery-state.3.4--abaa730bb10a.json @@ -0,0 +1,8 @@ +{ + "reviewId": "merge-recovery-state.3.4--abaa730bb10a", + "requirementId": "merge-recovery-state.3.4", + "hash": "abaa730bb10a", + "verdict": "pass", + "summary": "Independent review: The full policy rejects deleting or reordering the BEHIND synchronization branch or permitting it through the CLEAN/HAS_HOOKS enqueue gate.", + "timestamp": "2026-10-29T08:04:11.252Z" +} diff --git a/specs/merge-recovery-state.md b/specs/merge-recovery-state.md new file mode 100644 index 0000000..a852f46 --- /dev/null +++ b/specs/merge-recovery-state.md @@ -0,0 +1,64 @@ +# Merge Recovery State + +## Overview + +The shared `babysit-merge` skill sends a pull request that needs repair back to implementation. +The GitHub peer-reviewed and self-reviewed workflows call that state `ITERATING`; +the three RFC 2119 workflows call it `BUILDING`. Recovery instructions need to name +the implementation state that exists in the active workflow. Waiting for approval, +pending checks, or the merge queue does not establish that implementation needs repair. + +For this contract, repair evidence means a merge conflict (`mergeable=CONFLICTING` +or `mergeStateStatus=DIRTY`), a failed or cancelled latest required-check outcome +for the current PR head, or +`reviewDecision=CHANGES_REQUESTED`. A `BLOCKED` merge state alone is not repair +evidence. Failed or cancelled outcomes for older heads or superseded check runs +are not repair evidence. The contract concerns the rendered agent instructions; it does not add +a runtime PR classifier or a new workflow state. + +## Requirements + +### 1: Workflow-specific recovery + +1. For `2119-human-spec`, `2119-auto-spec`, and `2119-auto-sol`, the rendered + `babysit-merge` instruction for an open PR with repair evidence MUST direct + `set_state BUILDING`. +2. For `github-peer-reviewed` and `github-self-reviewed`, the rendered + `babysit-merge` instruction for an open PR with repair evidence MUST direct + `set_state ITERATING`. +3. The rendered `babysit-merge` instructions MUST NOT direct recovery to an + implementation state without repair evidence. + +Recovery uses the existing free-move contract in `REQ-009.5`: it bypasses the +normal transition graph and unresolved responsibilities, enters the destination, +and seeds fresh pending destination responsibilities. + +### 2: Evidence before recovery + +1. On each decision-tree invocation, the rendered `babysit-merge` instructions MUST + obtain fresh PR state, mergeability, merge state, review decision, queue membership, + and the latest required-check outcomes for the current PR head before deciding + whether an open PR needs repair. +2. The rendered `babysit-merge` instructions MUST NOT treat pending required checks, + failed optional checks, missing check results, or a check-query error as proof of + a failed required check. +3. For an open PR, the rendered `babysit-merge` instructions MUST evaluate repair + evidence before waiting for approval, checks, queue completion, or branch updates. +4. The rendered `babysit-merge` instructions MUST treat the recognized + `no required checks reported` response as an empty required-check set that by + itself establishes neither repair evidence, unavailable evidence, nor a + pending-check wait, leaving other PR evidence to determine the next action. + +### 3: Waits preserve the merge stage + +1. Without repair evidence, the rendered `babysit-merge` instructions MUST keep the + task in `MERGING` while approval is required, required checks are pending, the PR + is queued, the branch is behind, check evidence is unavailable, or a `BLOCKED` + merge state has no established cause. +2. For a `BLOCKED` open PR without repair evidence or an identified approval, check, + or queue wait, the rendered `babysit-merge` instructions MUST direct the agent to + report the unresolved blocker to the user and stop the current invocation. +3. For an approval wait without repair evidence, the rendered `babysit-merge` + instructions MUST return control to the user while retaining the state artifact. +4. For a behind branch without repair evidence, the rendered `babysit-merge` + instructions MUST require branch synchronization before attempting to queue the PR. diff --git a/src/panopticon/workflows/github_forge.py b/src/panopticon/workflows/github_forge.py index 31e9ebf..07453a8 100644 --- a/src/panopticon/workflows/github_forge.py +++ b/src/panopticon/workflows/github_forge.py @@ -35,6 +35,8 @@ class GithubForgeWorkflow(PlannedWorkflow): :meth:`plan_uri`, :meth:`_briefing_extras`) is inherited from :class:`~panopticon.workflows.planned_workflow.PlannedWorkflow`.""" + merge_recovery_state: ClassVar[str] = "ITERATING" + URL_RECORDED: ClassVar[Responsibility] = Responsibility( key="url-recorded", description="The PR URL is recorded on the task with the `set_url` MCP tool.", @@ -158,14 +160,12 @@ def skills(self) -> Sequence[Skill]: "none is running).\n" "- `requeue_count`: how many times `gh pr merge --auto` has been called " "(cap at 5).\n\n" - "## Pre-queue mergeability check\n\n" - "**Before touching the merge queue**, run:\n" - "`gh pr view --json state,mergeable,mergeStateStatus,autoMergeRequest`\n" - "If `mergeStateStatus` is `DIRTY` (conflicts) or `BEHIND`, stop and tell the user " - "to resolve that first with `babysit-ci`. Do not proceed.\n\n" "## Decision tree (run on every entry and re-invocation)\n\n" "**1. Fetch PR state** (always fresh):\n" - "`gh pr view --json state,mergeStateStatus,autoMergeRequest`\n\n" + "`gh pr view --json " + "state,headRefOid,mergeable,mergeStateStatus,reviewDecision,autoMergeRequest`\n" + "If this read fails or the PR state is unknown, retain MERGING and the state " + "artifact, report the unavailable evidence to the user, and stop.\n\n" "**2. Branch on `state`:**\n\n" "- **`MERGED`** → call the `advance` MCP operation (moves task to COMPLETE); " "delete the state artifact; stop.\n" @@ -173,17 +173,66 @@ def skills(self) -> Sequence[Skill]: "Delete the state artifact, leave a clear message for the user, and stop " "(the stop hook flips the turn to the user).\n" "- **`OPEN`** → continue to step 3.\n\n" - "**3. Branch on merge state / queue membership:**\n\n" - "- `mergeStateStatus` is `BLOCKED` or `DIRTY`, or required checks are failing / " - "changes requested → call `set_state ITERATING` with an explanation; " + "**3. Fetch required-check and queue evidence** (always fresh):\n" + "`gh pr checks --required --json name,bucket,state,link`\n" + "`gh pr checks` queries the current PR head. After the check query, re-read " + "it with `gh pr view --json headRefOid`. If it differs from step 1, discard " + "the evidence and restart the decision tree at step 1. If the re-read fails, " + "mark check evidence unavailable.\n" + "Use only the latest outcome of each required check for the current " + "`headRefOid`. Ignore older-head and superseded failed/cancelled runs; if " + "current-head/latest scope cannot be established, mark check evidence " + "unavailable. Do not use optional-check failures as repair evidence.\n" + "Read the returned buckets: `fail` and `cancel` are repair evidence; `pending` " + "means wait; `pass` and `skipping` are not failures. Classify JSON buckets, " + "not exit status; a pending or nonzero exit alone is not repair evidence. The recognized " + "`no required checks reported` response means an empty required-check set: " + "it establishes neither repair evidence, unavailable evidence, nor a " + "pending-check wait; let the other PR evidence decide. Other query errors or " + "missing/invalid results mean evidence is unavailable, not failed checks.\n" + "Read actual queue membership separately (`autoMergeRequest` alone is not " + "queue membership):\n" + "`gh api graphql --field owner= --field name= --field number= --raw-field " + "query='query($owner:String!,$name:String!,$number:Int!){repository(" + "owner:$owner,name:$name){pullRequest(number:$number){isInMergeQueue}}}'`\n" + "Use `data.repository.pullRequest.isInMergeQueue`; a query error or missing " + "value means queue evidence is unavailable. Do not infer false from a failed " + "read.\n\n" + "**4. Take the first matching branch** (repair before waits):\n\n" + "Without repair evidence, never move to an implementation state. Every " + "non-repair branch retains MERGING and the state artifact. A bare `BLOCKED` " + "or `UNKNOWN` value is not repair evidence.\n\n" + "- Repair evidence: `mergeable=CONFLICTING` or `mergeStateStatus=DIRTY`, " + "a latest current-head required-check bucket of `fail` or `cancel`, or " + "`reviewDecision=CHANGES_REQUESTED` → call " + f"`set_state {self.merge_recovery_state}` with an explanation; " "delete the state artifact; stop.\n" - "- `autoMergeRequest` is non-null, or the PR is already in the merge queue → " - "**skip** `gh pr merge --auto` (do not double-queue); go directly to step 4.\n" - "- Otherwise → run `gh pr merge --squash --auto`; increment `requeue_count` in " - "the state artifact. If `requeue_count` > 5, bail to the user and stop.\n\n" - "**4. Arm background watcher:**\n" + "- If check or queue evidence is unavailable → report the unavailable evidence " + "to the user and stop. Independent known repair evidence still takes precedence " + "above.\n" + "- If `mergeStateStatus=BEHIND` → tell the user to synchronize the branch with " + "`babysit-ci` before attempting to queue; stop. Do not queue a behind branch.\n" + "- If `reviewDecision=REVIEW_REQUIRED` → tell the user approval is required and " + "stop, returning control to the user. Do not arm a check watcher for an " + "approval wait.\n" + "- If `autoMergeRequest` is non-null or `isInMergeQueue` is true → skip " + "`gh pr merge --auto` (do not double-queue). If required checks are pending, " + "go to step 5; otherwise tell the user to re-invoke `babysit-merge` after queue " + "completion to observe `MERGED` and advance the task, then stop. A " + "completed-check watcher does not wait for a merge event.\n" + "- If required checks are pending → go to step 5 without queueing yet.\n" + "- If `mergeStateStatus=BLOCKED`, mergeability is unknown, or readiness is " + "otherwise unestablished → report the unresolved blocker to the user and stop. " + "Do not enqueue or loop on unknown evidence.\n" + "- Otherwise, only when mergeability is established and `mergeStateStatus` " + "is `CLEAN` or `HAS_HOOKS` → run `gh pr merge --squash --auto`; increment " + "`requeue_count` in the state artifact. If `requeue_count` > 5, bail to the " + "user and stop. After enqueueing, tell the user to re-invoke `babysit-merge` " + "after queue completion to observe `MERGED` and advance the task, then stop " + "with the state artifact retained.\n\n" + "**5. Arm background watcher** (only for known pending required checks):\n" "```\n" - "run_in_background: gh pr checks --watch 2>&1 " + "run_in_background: gh pr checks --required --watch 2>&1 " "| tee /tmp/babysit-merge-watch.log; " "echo EXIT:$? >> /tmp/babysit-merge-watch.log\n" "```\n" @@ -194,7 +243,8 @@ def skills(self) -> Sequence[Skill]: "user, delete the state artifact, and stop.\n" "2. If `watch_bash_id` is set and that background Bash task is **still running** → " "end the turn immediately (nothing to do yet).\n" - "3. Read `/tmp/babysit-merge-watch.log`. Find the last `EXIT:N` line.\n" + "3. If a watcher completed, read `/tmp/babysit-merge-watch.log` and find the " + "last `EXIT:N` line. A prior wait may have no watcher or log.\n" "4. Clear `watch_bash_id` in the state artifact (`null`).\n" "5. Re-enter the decision tree from step 1 above. Repeat until merged, blocked, " "or budget exceeded.", diff --git a/src/panopticon/workflows/spec_2119.py b/src/panopticon/workflows/spec_2119.py index 0ed5f75..f8c4608 100644 --- a/src/panopticon/workflows/spec_2119.py +++ b/src/panopticon/workflows/spec_2119.py @@ -294,6 +294,8 @@ class _Merging(State): class _Spec2119Workflow(GithubForgeWorkflow): """Shared forge skills for the 2119 workflow family.""" + merge_recovery_state: ClassVar[str] = "BUILDING" + #: Whether stage-4 adversarial review uses Fable plus Sol; test honesty always uses Sol. fable_reviews: ClassVar[bool] = True reviewers: ClassVar[tuple[ReviewerConfig, ReviewerConfig]] = ( diff --git a/tests/workflows/test_merge_recovery_state.py b/tests/workflows/test_merge_recovery_state.py new file mode 100644 index 0000000..d6f2f95 --- /dev/null +++ b/tests/workflows/test_merge_recovery_state.py @@ -0,0 +1,141 @@ +"""The merge babysitter's rendered recovery command works in every forge lifecycle.""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +from panopticon.core.models import Status +from panopticon.workflows.discovery import discover_workflows + + +@pytest.mark.parametrize( + ("workflow_name", "expected_state"), + [ + ("2119-human-spec", "BUILDING"), + ("2119-auto-spec", "BUILDING"), + ("2119-auto-sol", "BUILDING"), + ("github-peer-reviewed", "ITERATING"), + ("github-self-reviewed", "ITERATING"), + ], +) +def test_rendered_merge_recovery_enters_implementation_with_fresh_responsibilities( + workflow_name: str, expected_state: str +) -> None: + # 2119: merge-recovery-state.1.1 + # 2119: merge-recovery-state.1.2 + workflow = discover_workflows(_home_workflows=Path("/nonexistent"))[workflow_name] + instructions = next( + skill.instructions for skill in workflow.skills() if skill.name == "babysit-merge" + ) + recovery = re.search( + r"- Repair evidence: `mergeable=CONFLICTING` or `mergeStateStatus=DIRTY`, " + r"a latest current-head required-check bucket of `fail` or `cancel`, or " + r"`reviewDecision=CHANGES_REQUESTED` → call `set_state ([A-Z]+)` with an explanation; " + r"delete the state artifact; stop\.", + instructions, + ) + assert recovery is not None + rendered_target = recovery.group(1) + + task = workflow.start_task("task", "repo", at="t0") + workflow.force_transition(task, expected_state, at="t1", trigger="set-state") + for responsibility in task.outstanding_responsibilities: + task.resolve_responsibility(key=responsibility.key, status=Status.MET) + previous_implementation = task.current_entry + workflow.force_transition(task, "MERGING", at="t2", trigger="set-state") + merging = task.current_entry + assert task.outstanding_responsibilities + assert set(workflow.transitions("MERGING")) == {"COMPLETE", "DROPPED"} + + workflow.force_transition(task, rendered_target, at="t3", trigger="set-state") + + assert rendered_target == expected_state + assert task.state == expected_state + assert task.current_entry.from_state == "MERGING" + assert task.current_entry.to_state == expected_state + assert task.current_entry is not previous_implementation + assert task.current_entry.responsibilities == list(workflow.responsibilities(expected_state)) + assert task.current_entry.responsibilities + assert all(item.status is Status.PENDING for item in task.current_entry.responsibilities) + assert all(item.status is Status.MET for item in previous_implementation.responsibilities) + assert all(item.status is Status.PENDING for item in merging.responsibilities) + + +@pytest.mark.parametrize( + ("workflow_name", "expected_state"), + [ + ("2119-human-spec", "BUILDING"), + ("2119-auto-spec", "BUILDING"), + ("2119-auto-sol", "BUILDING"), + ("github-peer-reviewed", "ITERATING"), + ("github-self-reviewed", "ITERATING"), + ], +) +def test_merge_instructions_distinguish_repair_evidence_from_waits( + workflow_name: str, expected_state: str +) -> None: + # 2119: merge-recovery-state.1.3 + # 2119: merge-recovery-state.2.1 + # 2119: merge-recovery-state.2.2 + # 2119: merge-recovery-state.2.3 + # 2119: merge-recovery-state.2.4 + # 2119: merge-recovery-state.3.1 + # 2119: merge-recovery-state.3.2 + # 2119: merge-recovery-state.3.3 + # 2119: merge-recovery-state.3.4 + workflow = discover_workflows(_home_workflows=Path("/nonexistent"))[workflow_name] + instructions = next( + skill.instructions for skill in workflow.skills() if skill.name == "babysit-merge" + ) + # The artifact is the agent's decision procedure, not a Python classifier. Assert + # the complete ordered policy so a conflicting branch or unconditional watcher + # cannot satisfy this contract by merely mentioning the expected evidence words. + expected_policy = """## Decision tree (run on every entry and re-invocation) + +**1. Fetch PR state** (always fresh): +`gh pr view --json state,headRefOid,mergeable,mergeStateStatus,reviewDecision,autoMergeRequest` +If this read fails or the PR state is unknown, retain MERGING and the state artifact, report the unavailable evidence to the user, and stop. + +**2. Branch on `state`:** + +- **`MERGED`** → call the `advance` MCP operation (moves task to COMPLETE); delete the state artifact; stop. +- **`CLOSED`** (not merged) → the PR was closed without merging. Do NOT advance. Delete the state artifact, leave a clear message for the user, and stop (the stop hook flips the turn to the user). +- **`OPEN`** → continue to step 3. + +**3. Fetch required-check and queue evidence** (always fresh): +`gh pr checks --required --json name,bucket,state,link` +`gh pr checks` queries the current PR head. After the check query, re-read it with `gh pr view --json headRefOid`. If it differs from step 1, discard the evidence and restart the decision tree at step 1. If the re-read fails, mark check evidence unavailable. +Use only the latest outcome of each required check for the current `headRefOid`. Ignore older-head and superseded failed/cancelled runs; if current-head/latest scope cannot be established, mark check evidence unavailable. Do not use optional-check failures as repair evidence. +Read the returned buckets: `fail` and `cancel` are repair evidence; `pending` means wait; `pass` and `skipping` are not failures. Classify JSON buckets, not exit status; a pending or nonzero exit alone is not repair evidence. The recognized `no required checks reported` response means an empty required-check set: it establishes neither repair evidence, unavailable evidence, nor a pending-check wait; let the other PR evidence decide. Other query errors or missing/invalid results mean evidence is unavailable, not failed checks. +Read actual queue membership separately (`autoMergeRequest` alone is not queue membership): +`gh api graphql --field owner= --field name= --field number= --raw-field query='query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){isInMergeQueue}}}'` +Use `data.repository.pullRequest.isInMergeQueue`; a query error or missing value means queue evidence is unavailable. Do not infer false from a failed read. + +**4. Take the first matching branch** (repair before waits): + +Without repair evidence, never move to an implementation state. Every non-repair branch retains MERGING and the state artifact. A bare `BLOCKED` or `UNKNOWN` value is not repair evidence. + +- Repair evidence: `mergeable=CONFLICTING` or `mergeStateStatus=DIRTY`, a latest current-head required-check bucket of `fail` or `cancel`, or `reviewDecision=CHANGES_REQUESTED` → call `set_state ` with an explanation; delete the state artifact; stop. +- If check or queue evidence is unavailable → report the unavailable evidence to the user and stop. Independent known repair evidence still takes precedence above. +- If `mergeStateStatus=BEHIND` → tell the user to synchronize the branch with `babysit-ci` before attempting to queue; stop. Do not queue a behind branch. +- If `reviewDecision=REVIEW_REQUIRED` → tell the user approval is required and stop, returning control to the user. Do not arm a check watcher for an approval wait. +- If `autoMergeRequest` is non-null or `isInMergeQueue` is true → skip `gh pr merge --auto` (do not double-queue). If required checks are pending, go to step 5; otherwise tell the user to re-invoke `babysit-merge` after queue completion to observe `MERGED` and advance the task, then stop. A completed-check watcher does not wait for a merge event. +- If required checks are pending → go to step 5 without queueing yet. +- If `mergeStateStatus=BLOCKED`, mergeability is unknown, or readiness is otherwise unestablished → report the unresolved blocker to the user and stop. Do not enqueue or loop on unknown evidence. +- Otherwise, only when mergeability is established and `mergeStateStatus` is `CLEAN` or `HAS_HOOKS` → run `gh pr merge --squash --auto`; increment `requeue_count` in the state artifact. If `requeue_count` > 5, bail to the user and stop. After enqueueing, tell the user to re-invoke `babysit-merge` after queue completion to observe `MERGED` and advance the task, then stop with the state artifact retained. + +**5. Arm background watcher** (only for known pending required checks): +``` +run_in_background: gh pr checks --required --watch 2>&1 | tee /tmp/babysit-merge-watch.log; echo EXIT:$? >> /tmp/babysit-merge-watch.log +``` +Record the returned Bash task ID as `watch_bash_id` in the state artifact. End the turn (the watcher runs in the background). + +""".replace("", expected_state) + assert "## Pre-queue mergeability check" not in instructions + start = instructions.index("## Decision tree") + end = instructions.index("## Re-invocation") + assert instructions[start:end] == expected_policy + assert instructions.count("`set_state ") == 1 diff --git a/tests/workflows/test_spec_2119_artifacts.py b/tests/workflows/test_spec_2119_artifacts.py index 01b063e..c56921b 100644 --- a/tests/workflows/test_spec_2119_artifacts.py +++ b/tests/workflows/test_spec_2119_artifacts.py @@ -479,15 +479,17 @@ def test_babysit_merge_files_deferred_issues_before_the_merge_queue_steps() -> N assert {workflow.name for workflow in builtins} == set(WORKFLOW_NAMES) base_instructions = _skill(_workflow("github-peer-reviewed"), "babysit-merge").instructions + expected_queue_instructions = base_instructions.replace( + "`set_state ITERATING`", "`set_state BUILDING`" + ) for workflow in builtins: instructions = _skill(workflow, "babysit-merge").instructions - # The deferred-issue-filing instructions precede the untouched base merge-queue - # instructions (proving nothing about the queue mechanics itself was disturbed) and are - # exactly the required text — not merely a superset containing the right words. - assert instructions.endswith(base_instructions) - prefix = instructions[: -len(base_instructions)] + # The recovery state is the only family-specific change to the base merge-queue + # instructions. The deferred-issue prefix stays exact and precedes the queue steps. + assert instructions.endswith(expected_queue_instructions) + prefix = instructions[: -len(expected_queue_instructions)] assert prefix == EXPECTED_DEFERRED_ISSUES_MERGE_INSTRUCTIONS + "\n\n"