Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 50 additions & 3 deletions docs/PRD.html
Original file line number Diff line number Diff line change
Expand Up @@ -2330,6 +2330,30 @@ <h3 id="phase-4--expansion-post-v1">Phase 4 — Expansion (post-v1)</h3>
quotes a pull request which happens to merge inside the window certifies
on that merge; the window and the artifact gate are the bounds
(<code>internal/loopdriver/run.go</code>, <code>issues.go</code>).</p>
<p>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 <code>OPEN</code>
<em>before</em> the dispatch, so the false closes were unreachable: PR
#346/#347 (auto-closed by the sweep's <code>BaseBranchGone(main)</code>
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 <code>no-pr</code> with a landable branch left closed.
<code>prView</code> now also decodes
<code>baseRefName</code>/<code>headRefName</code>; a goal-named pull
request that is CLOSED, unmerged, based on <code>main</code>, with its
head ref still on origin becomes the merge subject, and
<code>verifyAndMergeRescue</code> reopens it (<code>gh pr reopen</code>,
repo-scoped, 60s-bounded, drain-tolerant), re-reads the state — the
reopen is verified, never assumed — and only then merges through the
ordinary <code>mergePRBounded</code> 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
<code>TestRunLoopClosedUnmergedGoalPRReopenedAndMerged</code>,
<code>TestRunLoopClosedGoalPRLandsNothingWhenNotReopenable</code>
(base-superseded, head-gone, reopen-refused) and
<code>TestVerifyAndMergeRescueRefusesNonReopenablePR</code>
(<code>internal/loopdriver/run.go</code>, <code>issues.go</code>).</p>
</blockquote>
<p><del>- <strong>Cross-board retry memory beyond the SHA guard</strong>
— commit 60638d3 stops re-issuing shipped goals, but re-queued failures
Expand Down Expand Up @@ -4901,9 +4925,32 @@ <h3 id="231-requirements">23.1 Requirements</h3>
chaos schedule) so "the driver works perfectly" is a checkable claim,
not a hope.</p>
<hr />
<p>*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
<p>*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
<code>OPEN</code> before the dispatch, so the false closes were
unreachable: PR #346/#347 (auto-closed by the zombie sweep's
<code>BaseBranchGone(main)</code> transport-noise bug minutes after they
opened, green and mergeable) and loop 282's #345 each recorded
<code>no-pr</code> with a landable branch left closed.
<code>prView</code> now decodes
<code>baseRefName</code>/<code>headRefName</code>, and a goal-named pull
request that is CLOSED, unmerged, based on <code>main</code> with its
head ref still on origin becomes the merge subject:
<code>verifyAndMergeRescue</code> reopens it (<code>gh pr reopen</code>,
repo-scoped, 60s wall, drain-tolerant), re-reads the state (gh's reopen
is verified, never believed) and only then merges through the existing
<code>mergePRBounded</code> 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
<code>TestRunLoopClosedUnmergedGoalPRReopenedAndMerged</code>,
<code>TestRunLoopClosedGoalPRLandsNothingWhenNotReopenable</code> and
<code>TestVerifyAndMergeRescueRefusesNonReopenablePR</code>
(<code>internal/loopdriver/run.go</code>, <code>issues.go</code>). *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
<code>BaseBranchGone(main)</code> transport-noise bug auto-closed both
within minutes, so the two verified heads were landed straight onto main
instead: <code>1f2ef8c</code> (<code>devagent/TASK-mtxyflr1-fq6y</code>
Expand Down
22 changes: 22 additions & 0 deletions docs/PRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.~~
Expand Down Expand Up @@ -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/<base>` 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.*
Expand Down
10 changes: 10 additions & 0 deletions docs/SELF-BUILD-LOOP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
81 changes: 81 additions & 0 deletions internal/loopdriver/DECISION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<repo>/branches/<ref>` 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 <n> --repo <tracker>` (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.)
Loading
Loading