Skip to content

fix(agent): three absorbing states get an escape — merge blocks, approval rows, and confidence parking - #9119

Merged
loopover-orb[bot] merged 1 commit into
mainfrom
fix/9012-9032-9034-stranded
Jul 27, 2026
Merged

fix(agent): three absorbing states get an escape — merge blocks, approval rows, and confidence parking#9119
loopover-orb[bot] merged 1 commit into
mainfrom
fix/9012-9032-9034-stranded

Conversation

@JSONbored

Copy link
Copy Markdown
Owner

Three separate paths where LoopOver stopped making progress on a PR and nothing anywhere said so. Same shape each time: a state with no exit and no signal.

#9012 — one token blip permanently stranded green, approved PRs

merge_blocked_sha was written for every terminal failure class and cleared by nothing; its only escape was the contributor pushing a new commit. But a 401 (App suspended / key rotated) and an exhausted secondary-rate-limit window are properties of the installation, not of the commit — they fail every in-flight merge in the fleet at once, and no contributor has any reason to push, because the PR looks green, approved, and (with review_state_label on) ready-to-merge.

classifyMergeFailure now reports a scope. Infra-scoped blocks carry an expiry (merge_blocked_until) and are re-probed; commit-scoped ones — a real conflict, a repo merge policy that forbids an App merge — keep the until-a-new-commit semantics byte-for-byte. A 401 stays terminal for the pass, so failing fast against a known-bad credential is unchanged.

Two compounding parts fixed alongside:

  • mergeAttemptCount never reset. Its own schema and function docs have promised "a new commit's attempts start fresh once the row's head advances" from the start, but bumpPullRequestMergeAttempt only scoped the increment to the head — the value survived every push. So once one head exhausted MERGE_RETRY_CAP, every later head was one-strike-terminal on the first transient failure it met.
  • The block was invisible. mergeBlockedReason reached no human-visible surface at all — the planner, audit and PostHog were its only readers — while the PR kept a ready-to-merge label. A blocked PR now takes the manual-review label with the reason in it.

Acceptance: a simulated 401 during merge → after the window passes, the PR merges autonomously with no new commit. A genuine merge conflict still blocks terminally until the contributor rebases. Both covered.

#9032 — the approval queue notified once, ever

stageForApproval returns early on !created and its badge dedup key is per (PR, actionClass) with no time component, so a maintainer who missed the single badge got no further prompt and the row waited indefinitely.

Pending rows now get a bucketed reminder badge per interval (the bucket index goes into the dedup key, so the ~2-minute sweep collapses to one badge per interval with no extra persisted state) and expire after a week.

Expiry is deliberately not a rejection: a rejection is a maintainer's judgment that the action was wrong and feeds the trust loop as such, while an expiry only records that consent was never given. Neither executes anything, and a later pass that re-plans the same action stages a fresh row with a fresh notification — a still-correct action should not be silenced because a human was on vacation once.

The pass rides the re-gate sweep's existing fan-out tick rather than adding a job type and a cron entry for a bounded DB scan, and runs before the fan-out so failing it cannot cost the tick its actual re-gate work.

#9034 — confidence parking never escalated

A sub-floor blocker still blocks, but under the default hold_for_review disposition it converts a one-shot close into an open hold — and nothing counted how many times the same PR re-entered it. A PR shaped to keep drawing low-confidence blockers survived indefinitely, cost a maintainer on every roll, and could be walked toward a merge from there.

Holds are now counted per distinct head, so the several re-gate passes one commit attracts spend one unit rather than several, and never reset by a push — repeated holds are the pattern being capped, so a push must not buy another life (the same reasoning bumpPullRequestDraftConversionCount already applies for the same reason). Past the cap the finding has been reproduced by independent passes, which is the corroboration a single pass's confidence number lacked, so the close is no longer the uncertain call the hold protects against.

Notes for review

Where the cap constant lives. It is in its own module, not in src/rules/advisory.ts. advisory.ts is one half of the hand-maintained gate-decision twin pair enforced by scripts/check-engine-parity.ts, and this cap has no engine counterpart to mirror — the engine's gate-advisory.ts carries no low-confidence hold resolver at all. Putting it there would have forced a no-op @loopover/engine release purely to satisfy the parity guard. advisory.ts is untouched by this PR.

Two extracted helpers. activeMergeBlockedSha and applyLowConfidenceHoldCap moved into the modules that own their semantics rather than staying inline in the re-gate pipeline. Both were unreachable for testing in place — reaching them needs a live gate evaluation, settings, GitHub state and a planner run, far too much machinery to stand up to observe one boolean. The merge one also keeps planAgentMaintenanceActions clock-free: the infra expiry resolves on the way in, not inside a pure function.

Migrations. 0185_merge_block_expiry.sql, 0186_low_confidence_hold_counter.sql. Both additive.

Verification

  • npm run test:ci — exit 0, full gate green.
  • 100% line and branch coverage on all 413 added src/ lines, verified by intersecting git diff -U0 against lcov.info DA:/BRDA: records.

Closes #9012
Closes #9032
Closes #9034

…oval rows, and confidence parking

Three separate paths where LoopOver stopped making progress on a PR and nothing
anywhere said so. Same shape each time: a state with no exit and no signal.

#9012 — one token blip permanently stranded green, approved PRs.
merge_blocked_sha was written for every terminal failure class and cleared by
nothing; its only escape was the contributor pushing a new commit. But a 401 (App
suspended / key rotated) and an exhausted secondary-rate-limit window are
properties of the INSTALLATION, not of the commit — they fail every in-flight
merge in the fleet at once, and no contributor has any reason to push, because the
PR looks green, approved, and (with review_state_label on) ready-to-merge.
classifyMergeFailure now reports a scope; infra-scoped blocks carry an expiry and
are re-probed, commit-scoped ones (real conflict, repo merge policy) keep the
until-a-new-commit semantics exactly. Two compounding parts fixed alongside:
mergeAttemptCount now actually resets when the head advances, which its own schema
and function docs have promised from the start but nothing implemented — so once
one head exhausted MERGE_RETRY_CAP every later head was one-strike-terminal — and
a blocked PR now takes the manual-review label naming the reason instead of
keeping a ready-to-merge promise it cannot deliver. mergeBlockedReason previously
reached no human-visible surface at all; the planner, audit and PostHog were its
only readers.

#9032 — the approval queue notified once, ever.
stageForApproval returns early on !created and its badge dedup key is per
(PR, actionClass) with no time component, so a maintainer who missed the single
badge got no further prompt and the row waited indefinitely. Pending rows now get
a bucketed reminder badge per interval and expire after a week. Expiry is
deliberately NOT a rejection: a rejection is a maintainer's judgment and feeds the
trust loop as such, while an expiry only records that consent was never given —
neither executes anything, and a later pass that re-plans the action stages a
fresh row. The pass rides the re-gate sweep's existing fan-out tick rather than
adding a job type and cron entry for a bounded DB scan, and runs before the
fan-out so failing it cannot cost the tick its actual work.

#9034 — confidence parking never escalated.
A sub-floor blocker still blocks, but under the default hold_for_review
disposition it converts a one-shot close into an OPEN hold, and nothing counted
how many times the same PR re-entered that hold. A PR shaped to keep drawing
low-confidence blockers therefore survived indefinitely, cost a maintainer on
every roll, and could be walked toward a merge from there. Holds are now counted
per distinct head (so the several re-gate passes one commit attracts spend one
unit, not several) and never reset by a push — repeated holds are the pattern
being capped, so a push must not buy another life. Past the cap the finding has
been reproduced by independent passes, which is the corroboration a single pass's
confidence number lacked, and the close fires.

The low-confidence cap lives in its own module rather than in src/rules/advisory.ts
on purpose: advisory.ts is one half of the hand-maintained gate-decision twin pair,
and this cap has no engine counterpart to mirror — the engine's gate-advisory.ts
carries no low-confidence hold resolver at all — so putting it there would have
forced a no-op engine release just to satisfy the parity guard.

Two helpers (activeMergeBlockedSha, applyLowConfidenceHoldCap) are extracted into
the modules that own their semantics rather than left inline in the re-gate
pipeline. Both were unreachable for testing in place: reaching them needs a live
gate evaluation, settings, GitHub state and a planner run, which is far too much
machinery to stand up to observe one boolean. It also keeps the planner clock-free
— resolving the infra expiry happens on the way in, not inside a pure function.

Migrations 0185 (merge_blocked_until) and 0186 (low_confidence_hold_count,
low_confidence_hold_head_sha).

Local gate green end to end (npm run test:ci, exit 0). 100% line and branch
coverage on all 413 added src lines.

Closes #9012
Closes #9032
Closes #9034
@superagent-security

Copy link
Copy Markdown
Contributor

Superagent didn't find any vulnerabilities or security issues in this PR.

@github-actions

Copy link
Copy Markdown
Contributor

Logic backtest

Replayed 0 historical case(s) for linked_issue_scope_mismatch through the base (cf4497f) and head (a25ed12) versions of its detection logic (corpus checksum 4f53cda18c2b).

Backtest comparison: linked_issue_scope_mismatch

Verdict: unchanged — no comparable axis moved.

Advisory only — this check never blocks merge (#8105).

@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.90%. Comparing base (31c7f21) to head (a25ed12).
⚠️ Report is 2 commits behind head on main.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #9119   +/-   ##
=======================================
  Coverage   93.89%   93.90%           
=======================================
  Files         813      815    +2     
  Lines       80809    80873   +64     
  Branches    24538    24564   +26     
=======================================
+ Hits        75876    75940   +64     
  Misses       3564     3564           
  Partials     1369     1369           
Flag Coverage Δ
backend 95.18% <100.00%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/db/repositories.ts 96.76% <100.00%> (+0.02%) ⬆️
src/db/schema.ts 73.11% <ø> (ø)
src/queue/job-dispatch.ts 99.41% <100.00%> (+0.01%) ⬆️
src/queue/processors.ts 95.69% <100.00%> (+<0.01%) ⬆️
src/review/low-confidence-hold-cap.ts 100.00% <100.00%> (ø)
src/services/agent-action-executor.ts 97.30% <100.00%> (+0.01%) ⬆️
src/services/agent-approval-queue.ts 99.29% <100.00%> (+0.12%) ⬆️
src/services/agent-approval-staleness.ts 100.00% <100.00%> (ø)
src/services/merge-failure.ts 100.00% <100.00%> (ø)
src/settings/agent-actions.ts 98.56% <100.00%> (+0.01%) ⬆️
... and 1 more

@loopover-orb loopover-orb Bot added the gittensor:bug Gittensor-scored bug fix — scores a 0.05x multiplier. label Jul 27, 2026
@loopover-orb

loopover-orb Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Tip

✅ LoopOver review result - approve/merge recommended

Review updated: 2026-07-27 02:45:40 UTC

18 files · 1 AI reviewer · no blockers · readiness 88/100 · CI green · clean

✅ Suggested Action - Approve/Merge

  • safe to merge

Review summary
This PR closes three related absorbing-state bugs (#9012, #9032, #9034) where a PR could stop making progress with no signal or recovery path: merge blocks now distinguish infra-scoped (401/rate-limit) causes that expire and re-probe from commit-scoped causes that persist until a new commit; a stale-approval sweep now reminds and expires pending auto_with_approval rows instead of waiting forever on one missed notification; and low-confidence AI-review holds are now capped so a PR can't survive indefinitely on repeated sub-floor rolls. Each fix has a matching migration (0185/0186, simple additive ALTER TABLE, D1-safe), matching schema/type updates, and is wired through the planner/executor with genuine DB-backed unit tests (merge-block-recovery, low-confidence-hold-cap, approval-queue-staleness) that exercise the real code paths including concurrency/idempotency edge cases (claimPendingAgentActionDecision race, malformed timestamps, missing head SHA). The mergeAttemptCount reset on head change and the mergeBlockedReason surfacing via the manual-review label both trace correctly to their described root causes rather than patching symptoms.

Nits — 6 non-blocking
  • This bundles three independently-scoped fixes (orb(disposition): merge_blocked_sha never clears and mergeAttemptCount never resets — one token blip permanently strands green approved PRs, invisibly #9012/orb(ux): approval-queue rows never expire and notify only once #9032/orb(gate): confidence-parking converts a deserved close into an indefinite open hold #9034) across ~18 files in one PR — reasonable given the shared 'absorbing state' theme and that each has its own migration/tests, but it does raise the review surface for a single cold pass.
  • src/services/agent-approval-queue.ts:519 hardcodes `https:​//github.com/${row.repoFullName}/pull/${row.pullNumber}` in the reminder deeplink — worth checking whether self-host/GHE deployments need a configurable base URL here.
  • applyLowConfidenceHoldCap (src/review/low-confidence-hold-cap.ts) writes an 'agent.low_confidence_hold.capped' audit row on every pass once the cap is exceeded, not just the pass that crosses it — likely intentional but could spam the audit trail on a PR stuck past the cap across many re-gate ticks.
  • sweepStaleApprovalQueue fetches a fixed `limit: 500` pending rows per tick with no pagination — fine at current scale but will silently stop covering older rows once the pending backlog exceeds that.
  • Consider whether the reminder deeplink in agent-approval-queue.ts should be built from a configurable base URL rather than a hardcoded github.com host.
  • PR author also opened the linked issue — Link an issue that was opened by a different contributor, or provide a rationale for why this self-authored issue represents genuine discovery work.

Decision drivers

  • ✅ Code review — No blockers (1 reviewer)
  • ✅ Gate result — Passing (No configured blocker found.)
Context & advisory signals — never blocks the verdict
Signal Result Evidence
Linked issue ✅ Linked #9012, #9032, #9034
Related work ✅ No active overlap found No same-issue or scoped active PR overlap found.
Change scope ❌ 8/20 High review scope from cached public metadata (3 linked issues).
Validation posture ✅ 25/25 PR body includes validation/test evidence.
Contributor workload ✅ 10/10 Author activity: 13 registered-repo PR(s), 13 merged, 274 issue(s).
Contributor context ✅ Confirmed Gittensor contributor JSONbored; Gittensor profile; 13 PR(s), 274 issue(s).
Improvement ✅ Minor risk: clean · value: minor · LLM: significant
Linked issue satisfaction

Addressed
The PR directly implements the issue's fix items: classifyMergeFailure now returns a scope (infra vs commit), infra-scoped blocks get a TTL (merge_blocked_until) that is re-probed via activeMergeBlockedSha, mergeAttemptCount is reset on head change and on infra-expiry, and mergeBlockedReason is surfaced via the manual-review label. Both acceptance criteria (401 auto-recovers without a new commit,

Review context
  • Author: JSONbored
  • Role context: owner (maintainer lane)
  • Public audience mode: oss maintainer
  • Lane context: Repository is configured for direct PR review.
  • Public profile languages: Python, TypeScript, Ruby, Go, MDX, Shell, Solidity, JavaScript
  • Official Gittensor activity: 13 PR(s), 274 issue(s).
  • PR-specific overlap: none found.
Contributor next steps
  • Start here: Treat this as maintainer-lane context rather than normal contributor-lane activity.
  • Then work through the remaining 2 steps in the Signals table above.
Signal definitions
  • Related work = same linked issue, overlapping active PRs, or title/path similarity.
  • Change scope = cached public metadata such as size labels, draft state, and review-burden hints.
  • Validation posture = whether the PR provides enough public validation/test evidence for maintainer review.
  • Contributor workload = public contributor activity and cleanup pressure, not a repo-wide quality failure.
  • Contributor context = public GitHub/Gittensor identity context; non-Gittensor status is not a blocker.
🧪 Chat with LoopOver

Ask LoopOver a question about this PR directly in a comment — grounded only in the same cached, public-safe facts shown above, never a new claim.

  • @loopover ask &lt;question&gt; answers contribution-quality Q&A with source citations and freshness.
  • @loopover chat &lt;question&gt; answers in natural prose from cached decision-pack facts via local inference (maintainer/collaborator; read-only).
  • A plain-language @loopover mention with a real question is routed to the closest matching read-only command automatically — no exact syntax required.

Full command reference: https://loopover.ai/docs/loopover-commands

🧪 Experimental — new and may change.

🟩 Safe / merged · 🟦 Advisory · 🟨 Held for review · 🟥 Blocked / closed


💰 Earn for open-source contributions like this. Gittensor lets GitHub contributors earn for the work they already do — register to start earning →.

Checked by LoopOver, a quiet PR intelligence layer for OSS maintainers.

  • Re-run LoopOver review

@loopover-orb loopover-orb Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LoopOver approves — the gate is satisfied and CI is green.

@loopover-orb
loopover-orb Bot merged commit 0746e6c into main Jul 27, 2026
9 checks passed
@loopover-orb
loopover-orb Bot deleted the fix/9012-9032-9034-stranded branch July 27, 2026 02:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gittensor:bug Gittensor-scored bug fix — scores a 0.05x multiplier.

Projects

None yet

1 participant