diff --git a/docs/PRD.html b/docs/PRD.html index 3dba22ed..a790a674 100644 --- a/docs/PRD.html +++ b/docs/PRD.html @@ -2330,6 +2330,30 @@

Phase 4 — Expansion (post-v1)

quotes a pull request which happens to merge inside the window certifies on that merge; the window and the artifact gate are the bounds (internal/loopdriver/run.go, issues.go).

+

2026-09-12 (the rescue reaches CLOSED-but-unmerged pull requests): +the driver-side verify-and-merge rescue — and the goal-text derivation +feeding it — only ever accepted a subject that read OPEN +before the dispatch, so the false closes were unreachable: PR +#346/#347 (auto-closed by the sweep's BaseBranchGone(main) +transport-noise bug minutes after they opened, green and mergeable) and +loop 282's #345 (CLOSED at dispatch, the worker's job being to reopen +it) all recorded no-pr with a landable branch left closed. +prView now also decodes +baseRefName/headRefName; a goal-named pull +request that is CLOSED, unmerged, based on main, with its +head ref still on origin becomes the merge subject, and +verifyAndMergeRescue reopens it (gh pr reopen, +repo-scoped, 60s-bounded, drain-tolerant), re-reads the state — the +reopen is verified, never assumed — and only then merges through the +ordinary mergePRBounded path. Every other close lands +nothing: a superseded/other base, a head ref gone from origin, a merge +stamp, and a reopen gh refuses all return 0 rather than fabricating a +ship. Pinned by +TestRunLoopClosedUnmergedGoalPRReopenedAndMerged, +TestRunLoopClosedGoalPRLandsNothingWhenNotReopenable +(base-superseded, head-gone, reopen-refused) and +TestVerifyAndMergeRescueRefusesNonReopenablePR +(internal/loopdriver/run.go, issues.go).

- Cross-board retry memory beyond the SHA guard — commit 60638d3 stops re-issuing shipped goals, but re-queued failures @@ -4901,9 +4925,32 @@

23.1 Requirements

chaos schedule) so "the driver works perfectly" is a checkable claim, not a hope.


-

*Last updated: 2026-09-12 (both green heads landed straight onto main -— no pull request, no re-implementation) — the zombie sweep was still -armed when PR #347 and PR #346 were opened, and its +

*Last updated: 2026-09-12 (the rescue reaches CLOSED-but-unmerged +pull requests: a goal-named closed PR is reopened and merged, or lands +nothing) — the driver-side verify-and-merge rescue, and the goal-text +derivation feeding it, only accepted subjects that read +OPEN before the dispatch, so the false closes were +unreachable: PR #346/#347 (auto-closed by the zombie sweep's +BaseBranchGone(main) transport-noise bug minutes after they +opened, green and mergeable) and loop 282's #345 each recorded +no-pr with a landable branch left closed. +prView now decodes +baseRefName/headRefName, and a goal-named pull +request that is CLOSED, unmerged, based on main with its +head ref still on origin becomes the merge subject: +verifyAndMergeRescue reopens it (gh pr reopen, +repo-scoped, 60s wall, drain-tolerant), re-reads the state (gh's reopen +is verified, never believed) and only then merges through the existing +mergePRBounded pipeline. Everything else lands nothing — +another/superseded base, a head ref gone from origin, a merge stamp, a +reopen gh refuses — so no ship is ever fabricated. Pinned by +TestRunLoopClosedUnmergedGoalPRReopenedAndMerged, +TestRunLoopClosedGoalPRLandsNothingWhenNotReopenable and +TestVerifyAndMergeRescueRefusesNonReopenablePR +(internal/loopdriver/run.go, issues.go). *Last +updated: 2026-09-12 (both green heads landed straight onto main — no +pull request, no re-implementation) — the zombie sweep was still armed +when PR #347 and PR #346 were opened, and its BaseBranchGone(main) transport-noise bug auto-closed both within minutes, so the two verified heads were landed straight onto main instead: 1f2ef8c (devagent/TASK-mtxyflr1-fq6y diff --git a/docs/PRD.md b/docs/PRD.md index 5b9ba418..a1157c18 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -1048,6 +1048,27 @@ Webhook-triggered runs with HMAC verification and dedup, run dashboard/status co > request which happens to merge inside the window certifies on that merge; > the window and the artifact gate are the bounds > (`internal/loopdriver/run.go`, `issues.go`). +> +> 2026-09-12 (the rescue reaches CLOSED-but-unmerged pull requests): the +> driver-side verify-and-merge rescue — and the goal-text derivation feeding +> it — only ever accepted a subject that read `OPEN` *before* the dispatch, so +> the false closes were unreachable: PR #346/#347 (auto-closed by the sweep's +> `BaseBranchGone(main)` transport-noise bug minutes after they opened, green +> and mergeable) and loop 282's #345 (CLOSED at dispatch, the worker's job +> being to reopen it) all recorded `no-pr` with a landable branch left closed. +> `prView` now also decodes `baseRefName`/`headRefName`; a goal-named pull +> request that is CLOSED, unmerged, based on `main`, with its head ref still +> on origin becomes the merge subject, and `verifyAndMergeRescue` reopens it +> (`gh pr reopen`, repo-scoped, 60s-bounded, drain-tolerant), re-reads the +> state — the reopen is verified, never assumed — and only then merges through +> the ordinary `mergePRBounded` path. Every other close lands nothing: a +> superseded/other base, a head ref gone from origin, a merge stamp, and a +> reopen gh refuses all return 0 rather than fabricating a ship. Pinned by +> `TestRunLoopClosedUnmergedGoalPRReopenedAndMerged`, +> `TestRunLoopClosedGoalPRLandsNothingWhenNotReopenable` (base-superseded, +> head-gone, reopen-refused) and +> `TestVerifyAndMergeRescueRefusesNonReopenablePR` +> (`internal/loopdriver/run.go`, `issues.go`). ~~- **Cross-board retry memory beyond the SHA guard** — commit 60638d3 stops re-issuing shipped goals, but re-queued failures still get a fresh attempt budget; carry the prior board's failure class onto the re-bridged goal so the scout deprioritizes until the root-cause fix lands (Q27).~~ ~~- **Regression oracle before board merge** — gates judge single PRs and PR #108's committed STRIDE allowlist widens suppression paths; add a board-level "is the system at least as good?" check (full suite on the merged result) ahead of `autoMerge`, per the Kitchen Loop zero-regression rule.~~ @@ -1674,6 +1695,7 @@ existing driver with validation surfaces (test, command, telemetry, chaos schedule) so "the driver works perfectly" is a checkable claim, not a hope. --- +*Last updated: 2026-09-12 (the rescue reaches CLOSED-but-unmerged pull requests: a goal-named closed PR is reopened and merged, or lands nothing) — the driver-side verify-and-merge rescue, and the goal-text derivation feeding it, only accepted subjects that read `OPEN` before the dispatch, so the false closes were unreachable: PR #346/#347 (auto-closed by the zombie sweep's `BaseBranchGone(main)` transport-noise bug minutes after they opened, green and mergeable) and loop 282's #345 each recorded `no-pr` with a landable branch left closed. `prView` now decodes `baseRefName`/`headRefName`, and a goal-named pull request that is CLOSED, unmerged, based on `main` with its head ref still on origin becomes the merge subject: `verifyAndMergeRescue` reopens it (`gh pr reopen`, repo-scoped, 60s wall, drain-tolerant), re-reads the state (gh's reopen is verified, never believed) and only then merges through the existing `mergePRBounded` pipeline. Everything else lands nothing — another/superseded base, a head ref gone from origin, a merge stamp, a reopen gh refuses — so no ship is ever fabricated. Pinned by `TestRunLoopClosedUnmergedGoalPRReopenedAndMerged`, `TestRunLoopClosedGoalPRLandsNothingWhenNotReopenable` and `TestVerifyAndMergeRescueRefusesNonReopenablePR` (`internal/loopdriver/run.go`, `issues.go`). *Last updated: 2026-09-12 (both green heads landed straight onto main — no pull request, no re-implementation) — the zombie sweep was still armed when PR #347 and PR #346 were opened, and its `BaseBranchGone(main)` transport-noise bug auto-closed both within minutes, so the two verified heads were landed straight onto main instead: `1f2ef8c` (`devagent/TASK-mtxyflr1-fq6y` — three-valued hygiene probes plus the auto-close age floor) fast-forwarded main, then `635214d` (`devagent/TASK-mtxwr39q-nva6` — the landing-certification merge window) merged on top. The code paths are disjoint (`internal/orchestrator` vs `internal/loopdriver`) and only `docs/PRD.*` overlapped. Neither PR was reopened or closed by this landing; both stay CLOSED, unmerged as pull requests. Verified on the merged tree: `go test ./...` green, including `TestAutoMergeTreatsBaseProbeNoiseAsAlive` and `TestGhNotFoundOnlyForRealGone`, and `git merge-base --is-ancestor` true for both heads. *Last updated: 2026-09-12 (zombie-PR hygiene probes are three-valued, with an auto-close age floor) — both gh probes behind the `pr-hygiene` sweep collapsed "gh never answered" into a decisive answer: `BaseBranchGone` returned `err != nil`, so a network hiccup, 5xx, or rate-limit response on the `branches/` lookup read as a dead base and the sweep CLOSED a live PR as `base-superseded` (PR #346: created 05:27:21Z, closed 05:28:37Z, 76s later, never merged); `landingCommitOnMain` returned empty on a failed or unparseable commit listing, so transport noise read as "no landing commit" and the PR was closed as `superseded` with an explicit "not shipped" comment. `ProbeBaseBranch` (internal/orchestrator/autopr.go) now answers `BaseAlive` / `BaseGone` / `BaseUnknown`, judging "gone" only on gh's own not-found stderr (`ghNotFound` reads the diagnostic, never the argv-embedding message, whose ref names may themselves contain "404"; `GhError.Code` is the gh exit code and is never consulted), and `landingCommitOnMain` returns `(sha, ok)` with `ok` false whenever the listing failed. On `unknown` the sweep records `untouched`/`base-unknown` or `untouched`/`landing-unknown` and acts on nothing; the merge-queue gate treats `BaseUnknown` as alive, so a probe hiccup no longer parks a mergeable PR. Both auto-close arms now also wait out an age floor: `closeAgeFloorHours` (24h) capped by `prHygiene.graceHours`, with an unknown PR age treated as floored — a fresh PR is reported `untouched`/`base-superseded-age-floor` or `untouched`/`landing-age-floor` and the `--grace-hours` help states the floor. Regression-pinned by `TestSweepTaskPrHygiene` (base-probe noise, fresh and unknown-age floors), `TestSweepTaskPrHygieneLandingEvidence` (landing noise, unparseable listing, landing floor), `TestAutoMergeTreatsBaseProbeNoiseAsAlive`, and `TestGhNotFoundOnlyForRealGone` — each fails against the pre-fix behavior.* *Last updated: 2026-09-12 (landing certification: the window, not the pre-dispatch stamp, is what certifies a ship) — the ship gate could only certify a pull request the OPEN guard had admitted before dispatch, so loop 282's reopen-and-merge (#345 merged 04:39:14Z inside its window) recorded non-productive: `gh pr view --json state` never said *when* a merge happened. `prView` now reads `state,mergedAt` in that one call (all five OPEN gates unchanged, `prState` a thin wrapper), a goal-named pull request whose merge stamp falls inside `[iterStart, now]` certifies `ok` (artifact gate included) on both the failed-dispatch and the rc-0 no-pr paths, an out-of-window stamp certifies nothing (loop 281), and a goal that IS a `Land PR #N` directive whose target had already merged before the iteration is refused pre-dispatch — `skipped` row, claim retired, no worker burned (loop 280). The refusal binds to the directive's position (the statement must open with it), so a tracker implement goal carrying an issue title that reads like a merge directive still dispatches instead of halting the loop through re-pick/refuse cycles. Pinned by `TestRunLoopGoalNamedMergedInWindowRecordsShip`, `TestRunLoopGoalNamedMergedAfterNoPRRunRecordsShip`, `TestRunLoopStaleGoalHeadMergedPRSkipsDispatch`, `TestRunLoopGoalNamedOutOfWindowPRRecordsNoPR`, `TestRunLoopTrackerTitleReadingLikeMergeDirectiveStillDispatches` and `TestRunLoopGoalQuotingMergeDirectiveMidProseStillDispatches`; ceiling — the record-time scan is mention-level, so the window and the artifact gate are what qualify a mention.* diff --git a/docs/SELF-BUILD-LOOP.md b/docs/SELF-BUILD-LOOP.md index 1253c9b1..32843e26 100644 --- a/docs/SELF-BUILD-LOOP.md +++ b/docs/SELF-BUILD-LOOP.md @@ -84,6 +84,16 @@ the Node tree (FR-GO-16, #205). precedent) rather than leaving it to re-claim and re-burn the row every lease; tracker-path iterations are unaffected (`queued == nil`). + **CLOSED-but-unmerged rescue (2026-09-12).** The derivation's OPEN guard and + the rescue's own OPEN guard made a falsely closed pull request unreachable + for exactly the class the route exists for — PR #346/#347, auto-closed by the + sweep's transport-noise bug minutes after they opened, green and mergeable. A + goal-named pull request that is CLOSED, unmerged, based on `main`, with its + head ref still on origin is now taken as the subject, and the rescue reopens + it (`gh pr reopen`, repo-scoped, bounded), re-reads the state — verified, + never believed — and merges through the ordinary pipeline. Any other close + (superseded base, head ref gone, merge stamp, reopen refused) lands nothing. + **Landing certification (2026-09-12).** The one `gh pr view` now carries `state,mergedAt`, so a merge is *dated* rather than only observed: a pull request the goal names whose merge falls inside this iteration's window diff --git a/internal/loopdriver/DECISION.md b/internal/loopdriver/DECISION.md index 136be6e8..03bd88d9 100644 --- a/internal/loopdriver/DECISION.md +++ b/internal/loopdriver/DECISION.md @@ -344,3 +344,84 @@ title that would have halted the loop) and quote). (Recorded 2026-09-12; TASK-mtxwr39q-nva6.) + +## DECISION: the rescue reaches CLOSED-but-unmerged pull requests (2026-09-12, TASK-mty0vgkg-oerc) + +Every mechanism above reads a pull request's state *before* the dispatch, and +`verifyAndMergeRescue` carried the same OPEN guard — so a pull request closed +unmerged was unreachable for all of them. That is exactly the class the route +exists for: #346 and #347 were auto-closed by the zombie sweep's +`BaseBranchGone(main)` transport-noise bug minutes after they opened (green, +mergeable, `origin/main` already contained), and loop 282's #345 was CLOSED at +dispatch with the worker's job being to reopen it. Each time the goal asked for +the pull request to be landed, the branch was landable, and the iteration +recorded `no-pr`. Three pieces, all read-only until the reopen: + +1. **`prView` widens.** One `gh pr view` now decodes + `state,mergedAt,baseRefName,headRefName`. The state and merge-stamp halves + are unchanged for every existing reader (all five OPEN gates, the window + certification); the two ref names are what the reopen gate judges. +2. **The goal-text derivation takes a closed subject.** When `pick.mergePR == + 0` and the goal names a merge target (`goalMergePR`), a CLOSED reference is + taken as the evidence subject while `reopenSubject` holds: no merge stamp, + `baseRefName == main`, and the head ref still resolving on origin + (`gh api repos//branches/` through `ghAPIDecode`, the driver's own + repo-scoped read). The OPEN case is untouched and takes precedence, and a + merged reference still falls through to the pre-dispatch refusal. +3. **The rescue reopens before merging.** `verifyAndMergeRescue` no longer + bails on a non-OPEN state: it re-checks the gate — the derivation vetted the + subject before the dispatch, and an iteration is long enough for a close to + land mid-flight — runs `gh pr reopen --repo ` (repo-scoped, + 60s wall, `pipeDrainDelay` teardown, the `prView` contract), and re-reads the + state. The reopen is verified, never believed: `gh pr reopen` exiting 0 + without gh reporting OPEN is a refusal. Only then does the ordinary + `mergePRBounded` → `AutoReviewAndMergeOne` path run — that pipeline skips any + non-OPEN pull request itself, which is why the reopen has to happen here + rather than inside it. + +Ceiling (named, not fixed): "reopenable" is judged from the pull request's own +refs, not from the reason it was closed — gh does not return that in the same +read, and inferring it from the sweep's close comment would couple the rescue to +prose. A closed-unmerged pull request on main with a live head that a human +closed on purpose (superseded by newer work, abandoned) is therefore reopened +and merged when the goal names it as its merge target and the run ships nothing. +What bounds it: the goal must carry a present-tense merge directive naming that +pull request (`mergePickRe`, the verb-proximity parse this whole route rests +on), the reopen must actually take, `AutoReviewAndMergeOne` must still pass it +(checks, hazard scan, review, base probe), and the artifact gate must find its +touched implementation files on main. The residual exposure is +`mergePickRe`'s, not this change's: the scan is the whole goal text, so an +*incidental* directive-shaped mention — a goal quoting `Goal: Land PR #N`, or a +tracker issue whose TITLE reads like one ("reopen and merge PR #N") — becomes a +subject too, where the OPEN case could only certify a merge that happened anyway +and this one can reopen. Two upgrade paths, in cost order: bind the closed case +to the directive's position (`goalHeadMergePR`, the pre-dispatch refusal's +bound — rejected here because loop 282's goal carries its directive in the +second sentence), or make the parse structural (`PICK: action=merge-pr pr=#N` +from the research prompt) and key both routes on it. For a deliberate close: the +sweep records its verdict (reason plus evidence) in the ledger and +`reopenSubject` refuses a close the driver itself made. + +Pinned by `TestRunLoopClosedUnmergedGoalPRReopenedAndMerged` (derivation → +reopen → merge → `ok`, with `gh pr reopen 344` ordered before `gh pr merge +344`), `TestRunLoopClosedGoalPRLandsNothingWhenNotReopenable` (a base-superseded +close and a head ref gone from origin never become subjects, and a reopen gh +refuses ends the rescue — all three keep the `no-pr` row with no merge) and +`TestVerifyAndMergeRescueRefusesNonReopenablePR` (the rescue's own re-check +refuses an ineligible subject before any mutation). The fake gh harness gained +the reopen/merge markers these need: a `pr reopen` decides whether later +`pr view` reads say OPEN, a `pr merge` decides whether they say MERGED. + +Live decode note (the fixtures' counterpart, per this file's precedent): the +widened read and the ref probe were checked against the real repository before +the fakes were trusted. `gh pr view 347 --repo FreePeak/devagent --json +state,mergedAt,baseRefName,headRefName` answers `{"baseRefName":"main", +"headRefName":"devagent/TASK-mtxyflr1-fq6y","mergedAt":null,"state":"CLOSED"}` — +an unmerged close carries a JSON `null` stamp, which decodes to the empty string +`mergedAtTime` already treats as no evidence — and `gh api +repos/FreePeak/devagent/branches/devagent/TASK-mtxyflr1-fq6y` resolves the +slash-containing ref, while a missing one answers exit 1 with "Branch not found +(HTTP 404)": the shape `GH_BRANCHES_GONE` reproduces. #347 is itself that class — +closed unmerged on main, its head ref still on origin. + +(Recorded 2026-09-12; TASK-mty0vgkg-oerc.) diff --git a/internal/loopdriver/issues.go b/internal/loopdriver/issues.go index 778bc013..8bf16dba 100644 --- a/internal/loopdriver/issues.go +++ b/internal/loopdriver/issues.go @@ -103,34 +103,44 @@ func (d *driver) closeIssue(num int, comment string) { _ = cmd.Run() } +// prViewResult is one `gh pr view --json state,mergedAt,baseRefName,headRefName` +// read. The state half answers the OPEN gates, the merge stamp answers the +// landing-evidence window, and the two ref names answer the CLOSED-but-unmerged +// reopen gate (run.go): a closed pull request is only reopenable while it still +// targets main and its head ref is still on origin. +type prViewResult struct { + State string `json:"state"` + MergedAt string `json:"mergedAt"` + BaseRefName string `json:"baseRefName"` + HeadRefName string `json:"headRefName"` +} + // prView reads gh's view of pull request num: its state (OPEN | MERGED | -// CLOSED, "" when gh cannot say) and its merge stamp mergedAt (second- -// precision RFC3339, empty when it has not merged). One -// `gh pr view --json state,mergedAt` per call, so the landing-evidence -// readers share a read instead of one gh call per field. Decoded rather than -// substring-matched: the verdicts gate a merge, gh's JSON shape is not -// contractual, and decoding is this file's own convention (pickIssue). -func (d *driver) prView(num int) (state, mergedAt string) { +// CLOSED, "" when gh cannot say), its merge stamp mergedAt (second-precision +// RFC3339, empty when it has not merged), and the base/head ref names the +// reopen gate needs. One `gh pr view --json …` per call, so the +// landing-evidence readers share a read instead of one gh call per field. +// Decoded rather than substring-matched: the verdicts gate a merge, gh's JSON +// shape is not contractual, and decoding is this file's own convention +// (pickIssue). +func (d *driver) prView(num int) prViewResult { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() cmd := exec.CommandContext(ctx, d.cfg.GhBin, "pr", "view", strconv.Itoa(num), - "--repo", d.cfg.GHRepo, "--json", "state,mergedAt") + "--repo", d.cfg.GHRepo, "--json", "state,mergedAt,baseRefName,headRefName") cmd.Dir = d.cfg.Repo cmd.WaitDelay = pipeDrainDelay out, err := cmd.Output() // A drain cutoff (ErrWaitDelay) must not read the view as failed when // gh itself exited 0 (#286). if err != nil && (cmd.ProcessState == nil || !cmd.ProcessState.Success()) { - return "", "" - } - var view struct { - State string `json:"state"` - MergedAt string `json:"mergedAt"` + return prViewResult{} } + var view prViewResult if err := json.Unmarshal(out, &view); err != nil { - return "", "" + return prViewResult{} } - return view.State, view.MergedAt + return view } // prState is the state-only half of prView — the reader the OPEN gates use. @@ -138,8 +148,7 @@ func (d *driver) prView(num int) (state, mergedAt string) { // inside it" reads prView instead (the landing-evidence certification in // run.go). func (d *driver) prState(num int) string { - state, _ := d.prView(num) - return state + return d.prView(num).State } // prMerged reports whether pull request num has merged — the publish evidence diff --git a/internal/loopdriver/run.go b/internal/loopdriver/run.go index 1137aab0..9186129f 100644 --- a/internal/loopdriver/run.go +++ b/internal/loopdriver/run.go @@ -9,9 +9,11 @@ package loopdriver import ( + "context" "fmt" "io" "os" + "os/exec" "path/filepath" "regexp" "strconv" @@ -393,8 +395,13 @@ func (d *driver) runIteration(n int, logF io.Writer, gradient, clusters string) // passes against a starvation limit of five. Derive the subject from the // goal text with the matcher researchPick parses, under the pick-time OPEN // guard above: a stale or incidental PR mention self-disqualifies, so - // nothing here can certify a do-nothing iteration. The derived PR then - // flows through the prMerged/evidencePR/landedArtifactsVerified wiring. + // nothing here can certify a do-nothing iteration. A closed-and-unmerged + // reference is the same subject one step earlier — the branch never + // landed, so the goal is still unfulfilled — and is taken only while the + // reopen gate (reopenSubject: main base, head ref on origin, unmerged) + // says the close is the only obstruction; the derivation then hands the + // rescue a subject it reopens before merging. The derived PR flows + // through the prMerged/evidencePR/landedArtifactsVerified wiring. // // The same read carries the pre-dispatch half of the certification: a // goal that IS a "Land PR #N" directive naming a pull request that had @@ -411,14 +418,23 @@ func (d *driver) runIteration(n int, logF io.Writer, gradient, clusters string) // loop into the starvation halt with nothing to retire. if pick.mergePR == 0 { if pr := goalMergePR(goal); pr != 0 { - state, mergedAt := d.prView(pr) - at, merged := mergedAtTime(mergedAt) + view := d.prView(pr) + at, merged := mergedAtTime(view.MergedAt) switch { - case state == "OPEN": + case view.State == "OPEN": pick.mergePR = pr _, _ = fmt.Fprintf(logF, "[verify] goal names open PR #%d — deriving the merge-evidence subject from the goal text\n", pr) + case d.reopenSubject(view): + // The CLOSED half of the same route: a pull request closed + // unmerged (the false zombie-sweep verdict of #346/#347, a + // superseded close, a stale close) still carries the branch + // the goal names, so it becomes the rescue's subject — which + // reopens it (reopenClosedPR) before merging. Only a + // reopenable close qualifies: main base, head ref on origin. + pick.mergePR = pr + _, _ = fmt.Fprintf(logF, "[verify] goal names closed-unmerged PR #%d based on %s with its head ref on origin — deriving the reopen-and-merge subject from the goal text\n", pr, mainBranch) case merged && at.Before(iterStart) && goalHeadMergePR(goal) == pr: - _, _ = fmt.Fprintf(logF, "[verify] goal-head PR #%d merged %s, before this iteration started — skipping dispatch\n", pr, mergedAt) + _, _ = fmt.Fprintf(logF, "[verify] goal-head PR #%d merged %s, before this iteration started — skipping dispatch\n", pr, view.MergedAt) d.record(logF, n, "skipped", goal) // The claim is retired for the same reason the shape gate // retires one: left live it re-claims after its lease and @@ -797,14 +813,20 @@ func (d *driver) mergePRBounded(n int, logF io.Writer, pr int) bool { // verifyAndMergeRescue closes the verify-and-merge loop for an iteration // whose run ended without shipping evidence: when the picked issue still has -// an OPEN pull request — research's pick (OPEN-guarded at pick time), a -// cross-referenced one, or one the run itself opened — the driver lands it -// through mergePRBounded and reports its number (0 = nothing landed). +// a pull request to land — research's pick (OPEN-guarded at pick time), a +// cross-referenced one, one the run itself opened, or the goal-named +// CLOSED-but-unmerged subject the derivation above took — the driver lands it +// through mergePRBounded and reports its number (0 = nothing landed). A +// closed subject is reopened first (reopenClosedPR) and only when the merge +// could not have happened without it; a refusal lands nothing. func (d *driver) verifyAndMergeRescue(n int, logF io.Writer, pr, issueNum int) int { if pr == 0 && issueNum != 0 { pr = d.openPROfIssue(issueNum) } - if pr == 0 || d.prState(pr) != "OPEN" { + if pr == 0 { + return 0 + } + if d.prState(pr) != "OPEN" && !d.reopenClosedPR(logF, pr) { return 0 } _, _ = fmt.Fprintf(logF, "[verify] run ended without shipping evidence — driver-side verify-and-merge of open PR #%d\n", pr) @@ -814,6 +836,69 @@ func (d *driver) verifyAndMergeRescue(n int, logF io.Writer, pr, issueNum int) i return 0 } +// mainBranch is the base branch a reopen-and-merge subject must target: the +// loop's own landing branch, hardcoded the way mainTreePaths' git/trees/main +// is. A pull request closed against anything else (a superseded base, a +// stacked branch) carries a branch the driver cannot land. +const mainBranch = "main" + +// reopenSubject reports whether a pull request the goal text names is a +// CLOSED-but-unmerged merge subject: closed (not merged), based on main, and +// its head ref still on origin. The class it serves is the false close — +// #346/#347 were auto-closed by the zombie sweep's BaseBranchGone +// transport-noise bug minutes after they opened, with green, mergeable +// branches — where the goal asks for the pull request to be landed and the +// only obstruction is the close itself. +// +// Read-only: the goal-text derivation asks it before deciding to dispatch, +// and reopenClosedPR asks it again before mutating anything. Every miss is +// the conservative direction (no subject, no reopen): a merged stamp, a base +// the driver does not land on, a deleted head ref, and a gh that cannot +// answer the ref probe all read as not reopenable, so a stale or incidental +// PR mention can never fabricate a ship. +func (d *driver) reopenSubject(view prViewResult) bool { + if view.State != "CLOSED" || view.HeadRefName == "" || view.BaseRefName != mainBranch { + return false + } + if _, merged := mergedAtTime(view.MergedAt); merged { + return false + } + return d.ghAPIDecode(fmt.Sprintf("repos/%s/branches/%s", d.cfg.GHRepo, view.HeadRefName), &struct{}{}) +} + +// reopenClosedPR reopens pull request pr and reports whether it is OPEN +// afterwards — the closed half of the rescue. gh's reopen is repo-scoped and +// bounded like every other driver gh call (60s wall, drain-tolerant teardown +// per #286), and its verdict is not believed: the state is re-read after the +// call, so "reopened" means gh says OPEN, never that the command exited 0. +// Anything else returns false and the rescue lands nothing — the +// base-superseded and reopen-refused classes. +func (d *driver) reopenClosedPR(logF io.Writer, pr int) bool { + if view := d.prView(pr); !d.reopenSubject(view) { + _, _ = fmt.Fprintf(logF, "[verify] PR #%d is %s (base %s, head %q) — not a reopen-and-merge subject\n", + pr, view.State, view.BaseRefName, view.HeadRefName) + return false + } + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, d.cfg.GhBin, "pr", "reopen", strconv.Itoa(pr), "--repo", d.cfg.GHRepo) + cmd.Dir = d.cfg.Repo + cmd.WaitDelay = pipeDrainDelay + out, err := cmd.CombinedOutput() + // Same drain tolerance as prView: a cutoff that crossed gh's own rc 0 is + // not a refusal. + if err != nil && (cmd.ProcessState == nil || !cmd.ProcessState.Success()) { + _, _ = fmt.Fprintf(logF, "[verify] gh pr reopen %d refused: %s\n", pr, strings.TrimSpace(string(out))) + return false + } + if state := d.prState(pr); state != "OPEN" { + _, _ = fmt.Fprintf(logF, "[verify] PR #%d did not reopen (state %s)\n", pr, state) + return false + } + _, _ = fmt.Fprintf(logF, "[verify] reopening closed-unmerged PR #%d to land it\n", pr) + return true +} + // applyPrHygieneTriage runs the shipped pr-hygiene sweep in apply mode after // a ship: zombie TASK PRs (base-superseded, red-across-grace) and the // landing-evidence triage of issues cited by open PRs (#336) act for real, @@ -914,7 +999,8 @@ var researchPickHeadingRe = regexp.MustCompile(`(?im)^\s*#+\s*pick\b`) // direction: the merge route prints no "PR opened:", so shipping rests on // prMerged, which is true for a PR that merged weeks ago, and the iteration // would record a productive row AND close an issue nobody worked. runIteration -// caps that: the route requires prState == OPEN at pick time, so every stale or +// caps that: the route requires a subject — OPEN at pick time, or (goal-text +// derivation) CLOSED-but-unmerged past the reopen gate — so every stale or // incidental PR reference self-disqualifies and a miss degrades to today's // implement dispatch. Upgrade path if phrasing ever defeats the parse: have the // research prompt emit `PICK: issue=#N action=merge-pr pr=#N @@ -989,11 +1075,11 @@ func (d *driver) goalMergedWithin(goal string, start, now time.Time) int { if err != nil || pr <= 0 { continue } - state, mergedAt := d.prView(pr) - if state != "MERGED" { + view := d.prView(pr) + if view.State != "MERGED" { continue } - at, ok := mergedAtTime(mergedAt) + at, ok := mergedAtTime(view.MergedAt) if !ok || at.Before(start) || at.After(now) { continue } diff --git a/internal/loopdriver/run_test.go b/internal/loopdriver/run_test.go index c03eb6bd..713326c3 100644 --- a/internal/loopdriver/run_test.go +++ b/internal/loopdriver/run_test.go @@ -76,7 +76,25 @@ case "$1 $2" in # gate's skip branch). Every other api call still answers the timeline. "api repos/"*"/pulls/"*"/files"*) printf '%s' "$GH_PR_FILES_JSON" ;; "api repos/"*"/git/trees/"*) printf '%s' "$GH_TREE_JSON" ;; + # The reopen gate's head-ref probe (repos//branches/): a + # decodable body means the ref resolves on origin, GH_BRANCHES_GONE answers + # the definitive 404 (the not-reopenable direction) instead. + "api repos/"*"/branches/"*) + if [ -n "${GH_BRANCHES_GONE:-}" ]; then echo "Branch not found (HTTP 404)" >&2; exit 1; fi + printf '{"name":"branch"}\n' ;; api\ *) printf '%s' "$GH_TIMELINE_JSON" ;; + # The rescue's reopen, modelled as the state change it is: a nonzero + # GH_PR_REOPEN_RC is the refusal; on success GH_PR_REOPEN_FILE appears and + # every later pr view reads OPEN. A pr merge marks GH_PR_MERGE_FILE the + # same way, so a merged state follows the action instead of a call count. + "pr reopen") + rc="${GH_PR_REOPEN_RC:-0}" + if [ "$rc" != "0" ]; then echo "could not reopen pull request" >&2; exit "$rc"; fi + [ -n "${GH_PR_REOPEN_FILE:-}" ] && : > "$GH_PR_REOPEN_FILE" + exit 0 ;; + "pr merge") + [ -n "${GH_PR_MERGE_FILE:-}" ] && : > "$GH_PR_MERGE_FILE" + exit 0 ;; # gh 2.98 pipes --json output (compact); prState decodes, so the shape is # the real one and the driver must not care. GH_PR_STATE answers every call; # GH_PR_STATE_FIRST + GH_PR_STATE_FILE sequence the first one differently @@ -95,14 +113,16 @@ case "$1 $2" in c=$((c + 1)); echo "$c" > "${GH_PR_STATE_OPEN_CALLS_FILE}" [ "$c" -le "$GH_PR_STATE_OPEN_CALLS" ] && s="OPEN" || s="${GH_PR_STATE:-OPEN}" fi + [ -n "${GH_PR_REOPEN_FILE:-}" ] && [ -f "$GH_PR_REOPEN_FILE" ] && s="OPEN" + [ -n "${GH_PR_MERGE_FILE:-}" ] && [ -f "$GH_PR_MERGE_FILE" ] && s="MERGED" # mergedAt rides the same --json read (gh omits the key when unset): # GH_PR_MERGED_AT answers the record-time landing certification, so a # MERGED stamp without it is gh-cannot-say, never a certified ship. - if [ -n "${GH_PR_MERGED_AT:-}" ]; then - printf '{"state":"%s","mergedAt":"%s"}\n' "$s" "$GH_PR_MERGED_AT" - else - printf '{"state":"%s"}\n' "$s" - fi ;; + # baseRefName/headRefName ride it too — the reopen gate reads them, and + # main + a TASK branch is the shape the live repo has. + printf '{"state":"%s","baseRefName":"%s","headRefName":"%s"' "$s" "${GH_PR_BASE_REF:-main}" "${GH_PR_HEAD_REF:-devagent/TASK-fixture}" + if [ -n "${GH_PR_MERGED_AT:-}" ]; then printf ',"mergedAt":"%s"' "$GH_PR_MERGED_AT"; fi + printf '}\n' ;; esac exit 0 `, @@ -1096,6 +1116,197 @@ func TestRunLoopGoalNamedOutOfWindowPRRecordsNoPR(t *testing.T) { } } +// The CLOSED-but-unmerged half of the goal-derived subject. #346/#347 were +// auto-closed by the zombie sweep's BaseBranchGone transport-noise bug minutes +// after they opened, with green, mergeable branches, so a goal that asks for +// the pull request to be landed named a subject the derivation's OPEN guard +// refused — and verifyAndMergeRescue's own OPEN guard refused it a second +// time. The rescue was unreachable for exactly the class it exists for. A +// closed-unmerged pull request is now a subject when the close is the only +// obstruction (main base, head ref on origin, no merge stamp), and the rescue +// reopens it before merging: gh's reopen is not believed, the state is +// re-read, and only then does the ordinary merge path run. +func TestRunLoopClosedUnmergedGoalPRReopenedAndMerged(t *testing.T) { + repo := initFixtureRepo(t) + installFakes(t, repo) + if _, err := queue.EnqueueTask(repo, queue.EnqueueInput{ + ID: "TASK-1", + Title: "Land PR 344", + Goal: "Goal: Land PR #344 (branch `devagent/TASK-mtxqd5xx-23cu`) — verify it and merge it; do not re-implement it.", + }); err != nil { + t.Fatal(err) + } + t.Setenv("DEVAGENT_FAKE_TASK_NO_PR", "1") + // Closed unmerged on main with its head ref still on origin — the false + // close the goal asks the loop to undo. The reopen/merge markers are the + // fake's state changes: OPEN after the reopen, MERGED after the merge. + t.Setenv("GH_PR_STATE", "CLOSED") + t.Setenv("GH_PR_HEAD_REF", "devagent/TASK-mtxqd5xx-23cu") + t.Setenv("GH_PR_REOPEN_FILE", filepath.Join(t.TempDir(), "reopened")) + t.Setenv("GH_PR_MERGE_FILE", filepath.Join(t.TempDir(), "merged")) + t.Setenv("GH_PR_FILES_JSON", `[{"filename":"internal/loopdriver/run.go"}]`) + t.Setenv("GH_TREE_JSON", `{"tree":[{"path":"internal/loopdriver/run.go","type":"blob"}],"truncated":false}`) + cfg := loopConfigFor(t, repo, func(c *LoopConfig) { + c.Now = func() time.Time { return certWindowNow() } + c.GHRepo = "FreePeak/devagent" + c.GhRun = execFakeGh + }) + cfg.DryRun = false + if rc := RunLoop(cfg); rc != 0 { + logData, _ := os.ReadFile(filepath.Join(repo, ".selfbuild", "logs", "loop-1.log")) + t.Fatalf("rc = %d, want 0\nlog:\n%s", rc, logData) + } + rows := readLedger(t, repo) + if len(rows) != 1 || rows[0]["status"] != "ok" { + t.Fatalf("rows: %v, want the ok row for a reopened-and-merged PR (never no-pr)", rows) + } + logData, err := os.ReadFile(filepath.Join(repo, ".selfbuild", "logs", "loop-1.log")) + if err != nil { + t.Fatal(err) + } + log := string(logData) + // Both ends of the route: the derivation took the closed subject, the + // rescue reopened it and only then merged. + if !strings.Contains(log, "[verify] goal names closed-unmerged PR #344 based on main with its head ref on origin — deriving the reopen-and-merge subject from the goal text") { + t.Fatalf("the closed subject was not derived:\n%s", log) + } + if !strings.Contains(log, "[verify] reopening closed-unmerged PR #344 to land it") { + t.Fatalf("the rescue did not reopen the closed PR:\n%s", log) + } + // gh's own actions, in order: reopen before merge. + calls, err := os.ReadFile(filepath.Join(repo, "devagent-calls.log")) + if err != nil { + t.Fatal(err) + } + reopenAt := strings.Index(string(calls), "gh pr reopen 344") + mergeAt := strings.Index(string(calls), "gh pr merge 344") + if reopenAt < 0 || mergeAt < 0 { + t.Fatalf("want both gh pr reopen 344 and gh pr merge 344, calls:\n%s", calls) + } + if reopenAt > mergeAt { + t.Fatalf("the merge ran before the reopen, calls:\n%s", calls) + } + // The ship is artifact-backed like every other merge verdict. + if !strings.Contains(log, "[verify] PR #344's implementation files verified on main") { + t.Fatalf("artifact verification not logged:\n%s", log) + } + assertFileContains(t, filepath.Join(repo, ".devagent", "queue", "TASK-1.json"), `"status": "done"`) +} + +// The reopen gate's misses land NOTHING: a close the driver may not undo (a +// base it does not land on, a head ref gone from origin) never becomes a +// subject, and a reopen gh refuses ends the rescue without a merge. All three +// keep the ordinary no-pr verdict with its claim retired — a fabricated ship +// is the failure mode this route exists to avoid. +func TestRunLoopClosedGoalPRLandsNothingWhenNotReopenable(t *testing.T) { + cases := []struct { + name string + env map[string]string + // wantReopenCall is false when the gate refuses the subject before any + // mutation, true when gh's reopen was attempted and refused. + wantReopenCall bool + wantLog string + }{ + { + name: "base-superseded: closed against another base", + env: map[string]string{"GH_PR_BASE_REF": "release/0.3"}, + }, + { + name: "head ref gone from origin", + env: map[string]string{"GH_BRANCHES_GONE": "1"}, + }, + { + name: "reopen refused by gh", + env: map[string]string{"GH_PR_REOPEN_RC": "1"}, + wantReopenCall: true, + wantLog: "[verify] gh pr reopen 344 refused: could not reopen pull request", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + repo := initFixtureRepo(t) + installFakes(t, repo) + if _, err := queue.EnqueueTask(repo, queue.EnqueueInput{ + ID: "TASK-1", + Title: "Land PR 344", + Goal: "Goal: Land PR #344 (branch `devagent/TASK-mtxqd5xx-23cu`) — verify it and merge it; do not re-implement it.", + }); err != nil { + t.Fatal(err) + } + t.Setenv("DEVAGENT_FAKE_TASK_NO_PR", "1") + t.Setenv("GH_PR_STATE", "CLOSED") + t.Setenv("GH_PR_HEAD_REF", "devagent/TASK-mtxqd5xx-23cu") + t.Setenv("GH_PR_REOPEN_FILE", filepath.Join(t.TempDir(), "reopened")) + t.Setenv("GH_PR_MERGE_FILE", filepath.Join(t.TempDir(), "merged")) + for k, v := range tc.env { + t.Setenv(k, v) + } + cfg := loopConfigFor(t, repo, func(c *LoopConfig) { + c.Now = func() time.Time { return certWindowNow() } + c.GHRepo = "FreePeak/devagent" + c.GhRun = execFakeGh + }) + cfg.DryRun = false + if rc := RunLoop(cfg); rc != 0 { + logData, _ := os.ReadFile(filepath.Join(repo, ".selfbuild", "logs", "loop-1.log")) + t.Fatalf("rc = %d, want 0\nlog:\n%s", rc, logData) + } + rows := readLedger(t, repo) + if len(rows) != 1 || rows[0]["status"] != "no-pr" { + t.Fatalf("rows: %v, want the no-pr row (nothing was landable)", rows) + } + calls, err := os.ReadFile(filepath.Join(repo, "devagent-calls.log")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(calls), "gh pr merge 344") { + t.Fatalf("a merge ran for a PR that was never reopened, calls:\n%s", calls) + } + if got := strings.Contains(string(calls), "gh pr reopen 344"); got != tc.wantReopenCall { + t.Fatalf("gh pr reopen 344 attempted = %v, want %v, calls:\n%s", got, tc.wantReopenCall, calls) + } + logData, err := os.ReadFile(filepath.Join(repo, ".selfbuild", "logs", "loop-1.log")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(logData), "[publish] task succeeded without opening a PR") { + t.Fatalf("the no-pr verdict was not recorded:\n%s", logData) + } + if tc.wantLog != "" && !strings.Contains(string(logData), tc.wantLog) { + t.Fatalf("want log %q:\n%s", tc.wantLog, logData) + } + }) + } +} + +// The rescue's own guard, unit-level: the derivation vetted the subject before +// dispatch, but the close can land while the run is in flight (the zombie +// sweep, a human, a base change), so verifyAndMergeRescue re-checks before +// mutating anything. A subject that no longer passes the gate is refused and +// nothing merges — the reachability the ticket adds must not become "reopen +// anything the goal names". +func TestVerifyAndMergeRescueRefusesNonReopenablePR(t *testing.T) { + repo := initFixtureRepo(t) + installFakes(t, repo) + t.Setenv("GH_PR_STATE", "CLOSED") + t.Setenv("GH_PR_BASE_REF", "release/0.3") + d := &driver{cfg: loopConfigFor(t, repo, func(c *LoopConfig) { c.GHRepo = "FreePeak/devagent" })} + var log bytes.Buffer + if pr := d.verifyAndMergeRescue(1, &log, 344, 0); pr != 0 { + t.Fatalf("verifyAndMergeRescue = %d, want 0 for a base-superseded close", pr) + } + if !strings.Contains(log.String(), "[verify] PR #344 is CLOSED (base release/0.3, head \"devagent/TASK-fixture\") — not a reopen-and-merge subject") { + t.Fatalf("the refusal was not logged:\n%s", log.String()) + } + calls, err := os.ReadFile(filepath.Join(repo, "devagent-calls.log")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(calls), "gh pr reopen") || strings.Contains(string(calls), "gh pr merge") { + t.Fatalf("an ineligible subject was mutated, calls:\n%s", calls) + } +} + // The position bound on the pre-dispatch refusal, loop 281's shape: the goal // QUOTES a merge directive ("PO goals like `Goal: Land PR #344`…") mid-prose // while its own work is something else, and that quoted pull request had