Skip to content
Closed
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
82 changes: 67 additions & 15 deletions docs/PRD.html
Original file line number Diff line number Diff line change
Expand Up @@ -2085,7 +2085,23 @@ <h3 id="phase-4--expansion-post-v1">Phase 4 — Expansion (post-v1)</h3>
<code>(#N)</code> landing commit on main, or <code>superseded</code>
with an explicit not-shipped comment when no landing commit exists; an
open issue keeps the PR open with a once-per-PR evidence comment
(internal/orchestrator/pr-hygiene.go). Legacy
(internal/orchestrator/pr-hygiene.go). Both sweep probes are
three-valued (2026-09-12): a base branch counts as <code>gone</code>
only on gh's own not-found answer, and the landing listing reports "no
landing commit" only when it actually answered — transport noise
(network failure, 5xx, rate limit) is <code>unknown</code>, so the sweep
acts on nothing (<code>untouched</code>/<code>base-unknown</code>,
<code>untouched</code>/<code>landing-unknown</code>) and the merge-queue
gate reads it as alive instead of parking a mergeable PR
(internal/orchestrator/autopr.go). No auto-close runs before an age
floor (2026-09-12): a definitive verdict alone no longer closes a PR —
<code>closeAgeFloorHours</code> (24h, capped by
<code>prHygiene.graceHours</code> so a long configured window never
delays cleanup by months) must also have elapsed, and a PR with an
unknown age is never closed. Fresh PRs are reported
<code>untouched</code> (<code>base-superseded-age-floor</code>,
<code>landing-age-floor</code>) carrying the verdict that will close
them once they age past the floor. Legacy
<code>mergeProjectBranches</code> gated so a board that published
per-task PRs no longer double-merges (PR #107,
<code>src/cli.ts:850</code>). Adapter-declared
Expand Down Expand Up @@ -4844,20 +4860,56 @@ <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><em>Last updated: 2026-09-12 (fallback-route landing evidence: the
ship verdict off the tracker path) — <code>pick.mergePR</code> is only
ever set by a tracker pick, so a queue- or PO-delivered goal
(<code>Goal: Land PR #N</code>) had no evidence subject at all: loops
270/271/274/280 each logged <code>falling back to LLM selection</code>,
each dispatched a goal naming the pull request to land, each of those
pull requests merged inside its own iteration window, and each recorded
the non-productive <code>no-pr</code> row — four false negatives in
eleven passes against a starvation limit of five, with the Q27
already-shipped guard equally blind. <code>runIteration</code> now
derives the merge target from the goal text (<code>goalMergePR</code>,
the <code>mergePickRe</code> researchPick already parses) before the
task dispatch, under the pick-time <code>prState == OPEN</code> guard,
so the derived pull request rides the existing <code>prMerged</code> →
<p><em>Last updated: 2026-09-12 (zombie-PR hygiene probes are
three-valued, with an auto-close age floor) — both gh probes behind the
<code>pr-hygiene</code> sweep collapsed "gh never answered" into a
decisive answer: <code>BaseBranchGone</code> returned
<code>err != nil</code>, so a network hiccup, 5xx, or rate-limit
response on the <code>branches/&lt;base&gt;</code> lookup read as a dead
base and the sweep CLOSED a live PR as <code>base-superseded</code> (PR
#346: created 05:27:21Z, closed 05:28:37Z, 76s later, never merged);
<code>landingCommitOnMain</code> returned empty on a failed or
unparseable commit listing, so transport noise read as "no landing
commit" and the PR was closed as <code>superseded</code> with an
explicit "not shipped" comment. <code>ProbeBaseBranch</code>
(internal/orchestrator/autopr.go) now answers <code>BaseAlive</code> /
<code>BaseGone</code> / <code>BaseUnknown</code>, judging "gone" only on
gh's own not-found stderr (<code>ghNotFound</code> reads the diagnostic,
never the argv-embedding message, whose ref names may themselves contain
"404"; <code>GhError.Code</code> is the gh exit code and is never
consulted), and <code>landingCommitOnMain</code> returns
<code>(sha, ok)</code> with <code>ok</code> false whenever the listing
failed. On <code>unknown</code> the sweep records
<code>untouched</code>/<code>base-unknown</code> or
<code>untouched</code>/<code>landing-unknown</code> and acts on nothing;
the merge-queue gate treats <code>BaseUnknown</code> as alive, so a
probe hiccup no longer parks a mergeable PR. Both auto-close arms now
also wait out an age floor: <code>closeAgeFloorHours</code> (24h) capped
by <code>prHygiene.graceHours</code>, with an unknown PR age treated as
floored — a fresh PR is reported
<code>untouched</code>/<code>base-superseded-age-floor</code> or
<code>untouched</code>/<code>landing-age-floor</code> and the
<code>--grace-hours</code> help states the floor. Regression-pinned by
<code>TestSweepTaskPrHygiene</code> (base-probe noise, fresh and
unknown-age floors), <code>TestSweepTaskPrHygieneLandingEvidence</code>
(landing noise, unparseable listing, landing floor),
<code>TestAutoMergeTreatsBaseProbeNoiseAsAlive</code>, and
<code>TestGhNotFoundOnlyForRealGone</code> — each fails against the
pre-fix behavior.</em> <em>Last updated: 2026-09-12 (fallback-route
landing evidence: the ship verdict off the tracker path) —
<code>pick.mergePR</code> is only ever set by a tracker pick, so a
queue- or PO-delivered goal (<code>Goal: Land PR #N</code>) had no
evidence subject at all: loops 270/271/274/280 each logged
<code>falling back to LLM selection</code>, each dispatched a goal
naming the pull request to land, each of those pull requests merged
inside its own iteration window, and each recorded the non-productive
<code>no-pr</code> row — four false negatives in eleven passes against a
starvation limit of five, with the Q27 already-shipped guard equally
blind. <code>runIteration</code> now derives the merge target from the
goal text (<code>goalMergePR</code>, the <code>mergePickRe</code>
researchPick already parses) before the task dispatch, under the
pick-time <code>prState == OPEN</code> guard, so the derived pull
request rides the existing <code>prMerged</code> →
<code>evidencePR</code> → <code>landedArtifactsVerified</code> wiring
and records <code>ok</code> with its artifacts verified — never
<code>no-pr</code>, while a stale mention self-disqualifies and a
Expand Down
15 changes: 15 additions & 0 deletions docs/PRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -841,6 +841,20 @@ Webhook-triggered runs with HMAC verification and dedup, run dashboard/status co
> `superseded` with an explicit not-shipped comment when no landing commit
> exists; an open issue keeps the PR open with a once-per-PR evidence
> comment (internal/orchestrator/pr-hygiene.go).
> Both sweep probes are three-valued (2026-09-12): a base branch counts as
> `gone` only on gh's own not-found answer, and the landing listing reports
> "no landing commit" only when it actually answered — transport noise
> (network failure, 5xx, rate limit) is `unknown`, so the sweep acts on
> nothing (`untouched`/`base-unknown`, `untouched`/`landing-unknown`) and the
> merge-queue gate reads it as alive instead of parking a mergeable PR
> (internal/orchestrator/autopr.go).
> No auto-close runs before an age floor (2026-09-12): a definitive verdict
> alone no longer closes a PR — `closeAgeFloorHours` (24h, capped by
> `prHygiene.graceHours` so a long configured window never delays cleanup by
> months) must also have elapsed, and a PR with an unknown age is never
> closed. Fresh PRs are reported `untouched` (`base-superseded-age-floor`,
> `landing-age-floor`) carrying the verdict that will close them once they
> age past the floor.
> Legacy `mergeProjectBranches` gated so a
> board that published per-task PRs no longer double-merges (PR #107,
> `src/cli.ts:850`). Adapter-declared `WorkerAdapter.isProgress` replaces the
Expand Down Expand Up @@ -1623,6 +1637,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 (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 (fallback-route landing evidence: the ship verdict off the tracker path) — `pick.mergePR` is only ever set by a tracker pick, so a queue- or PO-delivered goal (`Goal: Land PR #N`) had no evidence subject at all: loops 270/271/274/280 each logged `falling back to LLM selection`, each dispatched a goal naming the pull request to land, each of those pull requests merged inside its own iteration window, and each recorded the non-productive `no-pr` row — four false negatives in eleven passes against a starvation limit of five, with the Q27 already-shipped guard equally blind. `runIteration` now derives the merge target from the goal text (`goalMergePR`, the `mergePickRe` researchPick already parses) before the task dispatch, under the pick-time `prState == OPEN` guard, so the derived pull request rides the existing `prMerged` → `evidencePR` → `landedArtifactsVerified` wiring and records `ok` with its artifacts verified — never `no-pr`, while a stale mention self-disqualifies and a derived PR still open at record time is the driver-side verify-and-merge rescue's subject. The `no-pr` early return now also retires its queue claim `failed` with the `no-pr` detail (the shape gate's lease-recycling precedent) instead of leaving it to re-claim and re-burn the row every lease; tracker iterations are untouched (`queued == nil`). Pinned by `TestRunLoopGoalNamedOpenPRMergedRecordsShip`, `TestRunLoopGoalNamedMergedPRRecordsNoPR` and `TestGoalMergePR`; `internal/loopdriver/DECISION.md` records the decision.*
*Last updated: 2026-09-12 (landing evidence: a ship needs artifacts on main) — `merged = shipped` trusted gh's status stamp alone, and three consecutive iterations (276–278) proved the stamp lies: loop 276 recorded `ok` for PR #342 (merged only as an auto-cleanup snapshot — the test file it was meant to add never reached main) and loop 277 burned a full iteration re-running that already-"landed" goal. The driver's verify-and-merge path now verifies the artifact half: `landedArtifactsVerified` (internal/loopdriver/artifacts.go) lists the merged PR's touched files and main's tree over the `gh api` files API and requires every touched implementation file to exist on main (docs surfaces exempt — a PRD regen ships alone; a file the PR `deleted` is no claim, and a truncated tree list reads as cannot-say); a mismatch records the non-productive `landed-without-artifact` row and returns before the `ok` write, so the queue claim and tracker issue stay live, and a pick-time preflight greps the ledger for the goal's fingerprint (the Q27 guard's 60-char key) on those rows and skips the dispatch instead of re-reading the same MERGED stamp — retiring that claim as `failed` with the preflight detail (the shape gate's lease-recycling precedent) while the issue stays open. A gh that cannot answer logs and passes — evidence gate, not a second availability gate — and both decoders were checked against the real repository before the fixtures were trusted. Covered by `TestRunLoopMergedWithoutArtifactRecordsEvidenceRow`, `TestRunLoopMergePickRescuedByDriverSideMerge` (artifact-backed ship), `TestRunLoopPreflightSkipsGoalLandedWithoutArtifact`, `TestLandedArtifactsVerifiedIgnoresRemovedAndTruncated` and `TestLedgerLandingFingerprint`; the goal-shape fix PR #343 was recovered in the same iteration (re-opened, head 50891d5 verified with `go test ./...` in a throwaway worktree, merged as 38e6513).*
*Last updated: 2026-09-12 (goal-shape follow-up: queue-claim retirement + producer scope) — the shape gate's word cap binds every producer, not just PO output: a human goal from `POST /dispatch` or a scout-enqueued queue row past the 120-word statement budget is refused at the boundary too (the PO prompt is the one statement contract all dispatched goals trace back to; DECISION.md divergence #5 records the reasoning). The same pass closed the leak the cap opened: a queued claim whose goal is off-contract is now retired as `failed` with the rejection detail as its LastError at BOTH invalid sites (the goal-file gate and the taskDispatch boundary) — pre-fix the claim sat until its 2h lease lapsed, re-claimed, and re-burned an invalid row every cycle, walking the loop into the starvation halt with the row still live. Covered by `TestRunLoopInvalidQueuedGoalRetiresTheClaim` (run_test.go).*
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/actions_gates.go
Original file line number Diff line number Diff line change
Expand Up @@ -659,7 +659,7 @@ func newPrHygieneCmd() *cobra.Command {
},
}
cmd.Flags().String("repo", "", "target repository (used for gh context)")
cmd.Flags().Float64("grace-hours", -1, "hours a PR may stay red before being flagged (default from config prHygiene.graceHours)")
cmd.Flags().Float64("grace-hours", -1, "hours a PR may stay red before being flagged; also the auto-close age floor, capped at 24h (default from config prHygiene.graceHours)")
cmd.Flags().Bool("auto-merge", false, "report skipAutoMerge when a red-across-grace PR would have merged (default from config autoMerge)")
cmd.Flags().Bool("apply", false, "comment and close for real (default: config prHygiene.dryRun, itself defaulting to dry-run)")
return cmd
Expand Down
12 changes: 5 additions & 7 deletions internal/orchestrator/autopr-merge.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,13 +185,11 @@ func AutoReviewAndMergeOne(repoPath string, pr int, opts AutoReviewAndMergeOneOp
// Merge-queue gate 1 (base-superseded): a PR whose head base branch was
// merged or deleted can never integrate — waiting cannot fix a dead
// base, so skip it with a reason instead of parking the pipeline.
// Auto-close of such PRs stays owned by the pr-hygiene sweep. A probe
// hiccup cannot be distinguished from a dead base through the seam's
// boolean — the TS try/catch swallows probe errors into "fall through";
// the scripted seam mirrors gh by throwing only on real 404s, so this
// port probes through an error-carrying call and treats transport noise
// as alive (matching the tolerant TS behavior for non-404 failures).
if BaseBranchGone(repoPath, status.BaseRefName, run) {
// Auto-close of such PRs stays owned by the pr-hygiene sweep. Only a
// definitive gh 404 skips the PR: the probe is three-valued, and
// transport noise (BaseUnknown) reads as alive so a probe hiccup never
// parks a mergeable PR.
if ProbeBaseBranch(repoPath, status.BaseRefName, run) == BaseGone {
return AutoMergeOutcome{
PR: pr,
Title: status.Title,
Expand Down
54 changes: 48 additions & 6 deletions internal/orchestrator/autopr.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package orchestrator

import (
"encoding/json"
"errors"
"fmt"
"math"
"regexp"
Expand Down Expand Up @@ -566,13 +567,54 @@ func GetPrStatus(repoPath string, pr int, run RunGh) (PrStatus, error) {
return parsePr(raw), nil
}

// BaseBranchGone mirrors the TS baseBranchGone: a base branch is gone when
// gh cannot resolve the ref (merged+deleted or deleted directly). Shared by
// the merge-queue gate and the TASK-PR hygiene sweep: a PR whose head base
// branch died can never integrate.
func BaseBranchGone(repoPath string, base string, run RunGh) bool {
// BaseProbe is the three-valued verdict of a gh reachability probe. The TS
// booleans collapsed "gh answered not-found" and "gh never answered" into one
// value, so transport noise (network failure, 5xx, rate limit) read as a dead
// ref. The Go port keeps the unknown state explicit: callers act on a
// definitive verdict only, never on Unknown.
type BaseProbe string

const (
BaseAlive BaseProbe = "alive" // the ref resolved: gh answered
BaseGone BaseProbe = "gone" // gh answered not-found (merged+deleted or deleted)
BaseUnknown BaseProbe = "unknown" // gh never answered: transport noise
)

// ghNotFoundRe matches gh's not-found signal: `gh api` reports an HTTP 404 as
// exit 1 with "Branch not found (HTTP 404)" / "Not Found (HTTP 404)" on
// stderr. The exit code cannot separate that from a network hiccup, so the
// probe classifies on the diagnostic text.
var ghNotFoundRe = regexp.MustCompile(`(?i)not found|http 404`)

// ghNotFound reports whether a probe failure is a definitive gh 404 rather
// than transport noise. A *GhError is judged on its stderr alone — its message
// embeds the argv, whose ref names may themselves contain "404".
func ghNotFound(err error) bool {
if err == nil {
return false
}
var ge *GhError
if errors.As(err, &ge) {
return ghNotFoundRe.MatchString(ge.Stderr)
}
return ghNotFoundRe.MatchString(err.Error())
}

// ProbeBaseBranch is the three-valued successor of the TS boolean
// baseBranchGone: a base branch is gone when gh answers not-found
// (merged+deleted or deleted directly), alive when the ref resolves, and
// unknown when the lookup itself failed. Shared by the merge-queue gate and
// the TASK-PR hygiene sweep; a probe hiccup must never read as a dead base.
func ProbeBaseBranch(repoPath string, base string, run RunGh) BaseProbe {
_, err := run([]string{"api", fmt.Sprintf("repos/{owner}/{repo}/branches/%s", base)}, repoPath)
return err != nil
switch {
case err == nil:
return BaseAlive
case ghNotFound(err):
return BaseGone
default:
return BaseUnknown
}
}

// ListOpenPrs mirrors the TS listOpenPrs.
Expand Down
Loading
Loading