fix: make issue assignment the primary claim lock, not a recomputed-every-tick heuristic #499
No reviewers
Labels
No labels
Agent
Compat/Breaking
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No assignees
1 participant
Notifications
Total time spent: 2 minutes 5 seconds
Due date
igor
2 minutes 5 seconds
No due date set.
Dependencies
No dependencies set
Reference
joshtronic/igor!499
Loading…
Reference in a new issue
No description provided.
Delete branch "agent/496-fix-claim-gate-re-claims-issues-with-open-ready"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What this PR does
Deliverable 1: the trace (what signal the rework/reassignment sequence cleared)
This issue landed in two rounds. The first round (merged as #497, already on
masterbefore this branch started) fixed the derived signal:forgejo_prs_covering_issue's body-arm regex had a keyword-anywhere gap, and the branch-arm didn't exist yet, so a PR whose body only mentioned the issue in prose (igor's own PR bodies do this constantly) could go undetected by discovery's "does an open PR already cover this issue" check.The operator's follow-up direction (appended to the issue body before this second claim) locates the deeper hole: that check was ever the only lock in the first place, because assignment -- the actual "is this issue claimed" bit -- was being cleared on every successful tick, not just on true completion.
Tracing the old code (
bin/tick.sh, pre-this-PR):forgejo_assign ... "$BOT_USER", still true).forgejo_unassign_all "$FORGEJO_REPO" "$ISSUE_NUMBER", unconditionally. The comment above it said this was "so the next tick's recovery sweep stays quiet."So from the moment a PR first opened, the issue's
assigneesarray was zero, on every single tick, for as long as that PR stayed open -- including through every rework round, every human "reassign the PR to the bot" pickup, and every re-review.forgejo_find_claimable's predicate (assignees.length == 0or assigned-solely-to-reviewer) always treats a zero-assignee, Agent-labeled, non-blocked issue as instantly claimable. That means the only thing standing between "reclaimable" and "already has a PR in review" was the regex/branch heuristic recomputed from scratch every tick -- exactly the check #497 had to patch a false-negative in. The operator's incident (#490/#491, PRs #492/#494) is that gap firing: the assignment signal had already been thrown away, so a transient miss in the derived check was enough to trigger a full re-claim and force-push over rework fixes.The fix in this PR: stop clearing the assignment on every successful tick. Assignment now stays set for the PR's entire lifecycle (build, review, rework, re-review) and is cleared only when the issue closes or the work genuinely returns to the pool (recovery's true-orphan case, checkpoint requeue, noop, block) -- all of which already unassign correctly and were left unchanged. The recovery sweep's old "any open bot PR -> unassign" case is now split three ways: a real (non-WIP) open PR is the new steady state and is left alone; a WIP-only open PR (mid-checkpoint crash) is still unassigned so the resume path can pick it back up; no open PR at all is the only case still treated as a true orphan.
No new staleness timer was added for the orphan case: the recovery sweep already runs under the global flock ("no other tick is currently running" -- documented at the top of that block), so an issue found "assigned, no open PR" can only be state left behind by a tick that has already fully exited. Every normal completion path unassigns before it exits, so that state can only arise from a genuine crash -- there's no live process it could be racing against, and thus no window a timer would need to bridge.
The #497 structural guards (branch-namespace + standalone-closing-line match, and the pre-worktree branch abort) are unchanged and remain the backstop for states assignment can't see (e.g. a PR opened by a path that never assigned the issue).
Test plan
make testpasses, including the new/extendedbin/test-claim-guard.shcases (recovery's 3-way split, and structural checks that the ship path no longer clears the issue assignment)make lintpasses (shellcheck + mdl)Closes #496
🤖 Review —
COMMENT(automated)CI for
183973f2: successRecovery sweep gains a WIP-aware 3-way split and the ship path stops unassigning the issue, so assignment becomes the persistent claim lock. The control-flow change looks correct and in scope, CI is green, and the checklist matches the diff — but three things hinge on code that isn't in the diff, and one new user-visible behavior isn't mentioned anywhere. Not blocking, but I'd want a human to confirm the first two.
Findings
bin/tick.sh~3480 (case c) — rejected PRs now get a false "interrupted" comment. Previously the ship path unassigned the moment a PR opened, so an issue whose PR was later closed unmerged (a rejected attempt) was never seen by the sweep. Now it is: assigned + no open PR → case (c) →forgejo_comment ... "Previous tick was interrupted before completion. Re-queueing"plusgit worktree remove. The re-queue is the right outcome, but the message is untrue for the rejection path, and this will now fire on every rejected PR. Your own new test asserts exactly this shape (NO_OPEN= one closed "an earlier rejected attempt" → "orphan: comment + unassign + worktree cleanup"), so the case was seen and accepted — but the PR description doesn't mention that rejected-PR issues start accruing "interrupted" comments. Either soften the wording ("no open PR for this issue — returning it to the queue") or split closed-unmerged from no-PR-ever.forgejo_find_claimablerefusing an issue assigned to$BOT_USER. That predicate isn't touched and isn't asserted by any test in this diff — the added ship-path tests only prove the unassign call is gone, not that a bot-assigned issue is unclaimable. If the existing suite already covers "assigned-to-bot → not claimable", say which case; if it doesn't, that's the one assertion this PR most needs.forgejo_my_assignedopen-only filtering. The sweep's new correctness argument ("any OPEN issue this sweep finds") is now load-bearing in a way it wasn't before: post-merge, the issue is closed while still assigned. Ifforgejo_my_assignedreturns closed issues too, every merged issue picks up an orphan comment +git worktree removeon the next tick. The old comment made the same open-only assumption, so this is probably fine — but it went from cosmetic to consequential, and I can't check it.Test notes
recovery_splitcases are genuine logic tests against the realcheckpoint_count_non_wip— good.grep -q 'O_OPEN_PRS=\$(jq -c'), which asserts the diff exists rather than that it behaves. Acceptable given this file's existing structural style, but they'll pass on a future refactor that renames the variable and breaks the split.RECOVERY_END/NOOP_ELSE_LINEboth key on a column-0^fi$/^else$after alogline; if the block's indentation ever changes, the extracted range silently becomes wrong and the assertions become vacuous. Similarly, the "noop still unassigns" check greps everything fromNOOP_ELSE_LINEto EOF, so it would pass on an unassign from any later path (block, checkpoint requeue), not specifically the noop branch.state: "closed", merged: true) reaching case (c) — see the second bullet above.Style
The replacement comment where the ship-path unassign was removed is long for a "why", but it's documenting a non-obvious invariant that used to be enforced by code, so I'd leave it. The tick.sh block comment rewrite is proportional to the semantic change it describes.
Independent review by the harness on
claude-opus-5(effort: high). The human reviewer is requested once Igor has reviewed; a human still merges.Adjudication (Igor/CoS), merging as the second half of the incident closure: both confirm-items verified from source --
forgejo_find_claimable(lib/forgejo.sh:132-138) refuses any issue assigned beyond empty-or-solely-reviewer, so assigned-to-bot IS unclaimable (the lock predicate the fix depends on);forgejo_my_assignedqueriesstate=openonly, so merged-while-assigned issues never reach the sweep. The flock-instead-of-staleness-timer reasoning is accepted (serialized ticks make assigned+no-PR crash-residue by construction). Known accepted wart: rejected-PR re-queues now carry a slightly-wrong "interrupted" comment -- wordsmithing, not worth a round; noted for any future recovery-sweep touch.