fix: make issue assignment the primary claim lock, not a recomputed-every-tick heuristic #499

Merged
igor merged 1 commit from agent/496-fix-claim-gate-re-claims-issues-with-open-ready into master 2026-08-09 22:27:02 +00:00
Collaborator

What this PR does

  • fix: make issue assignment the primary claim lock, not a recomputed-every-tick heuristic
  • Recovery sweep now leaves a bot-assigned issue alone while a real (non-WIP) PR covers it, instead of stripping the assignment every tick
  • Recovery sweep splits WIP-checkpoint-only vs no-PR-at-all cases explicitly (checkpoint resume path unchanged; true orphans still re-queued)
  • The ship path no longer unassigns the issue the moment a PR opens or a rework round finalizes it -- it now stays assigned for the PR's whole lifecycle
  • Tests: recovery's 3-way split (real PR / WIP-only / no PR, plus rejected-history-alongside-a-live-PR), and structural wiring checks that the ship path no longer clears the issue assignment

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 master before 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):

  1. Claim assigns the bot to the issue (forgejo_assign ... "$BOT_USER", still true).
  2. On a successful ship -- whether that ship opened a brand-new PR or finalized an already-open one after a rework round -- the very last thing the "outcome: PR" branch did was forgejo_unassign_all "$FORGEJO_REPO" "$ISSUE_NUMBER", unconditionally. The comment above it said this was "so the next tick's recovery sweep stays quiet."
  3. The recovery sweep's own case (b) reinforced the same clearing from the other direction: any tick that found the bot still assigned to an issue with an open bot PR would unassign it too, on the theory that this was leftover cleanup from before "fix/issue-lifecycle-design" landed.

So from the moment a PR first opened, the issue's assignees array 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 == 0 or 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 test passes, including the new/extended bin/test-claim-guard.sh cases (recovery's 3-way split, and structural checks that the ship path no longer clears the issue assignment)
  • make lint passes (shellcheck + mdl)
  • No manual verification needed beyond the above; this is pure harness control-flow with no UI surface

Closes #496

## What this PR does - [x] fix: make issue assignment the primary claim lock, not a recomputed-every-tick heuristic - [x] Recovery sweep now leaves a bot-assigned issue alone while a real (non-WIP) PR covers it, instead of stripping the assignment every tick - [x] Recovery sweep splits WIP-checkpoint-only vs no-PR-at-all cases explicitly (checkpoint resume path unchanged; true orphans still re-queued) - [x] The ship path no longer unassigns the issue the moment a PR opens or a rework round finalizes it -- it now stays assigned for the PR's whole lifecycle - [x] Tests: recovery's 3-way split (real PR / WIP-only / no PR, plus rejected-history-alongside-a-live-PR), and structural wiring checks that the ship path no longer clears the issue assignment ## 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 `master` before 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): 1. Claim assigns the bot to the issue (`forgejo_assign ... "$BOT_USER"`, still true). 2. On a successful ship -- **whether that ship opened a brand-new PR or finalized an already-open one after a rework round** -- the very last thing the "outcome: PR" branch did was `forgejo_unassign_all "$FORGEJO_REPO" "$ISSUE_NUMBER"`, unconditionally. The comment above it said this was "so the next tick's recovery sweep stays quiet." 3. The recovery sweep's own case (b) reinforced the same clearing from the other direction: *any* tick that found the bot still assigned to an issue with an open bot PR would unassign it too, on the theory that this was leftover cleanup from before "fix/issue-lifecycle-design" landed. So from the moment a PR first opened, the issue's `assignees` array 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 == 0` or 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 - [x] `make test` passes, including the new/extended `bin/test-claim-guard.sh` cases (recovery's 3-way split, and structural checks that the ship path no longer clears the issue assignment) - [x] `make lint` passes (shellcheck + mdl) - [x] No manual verification needed beyond the above; this is pure harness control-flow with no UI surface Closes #496
fix: make issue assignment the primary claim lock, not a recomputed-every-tick heuristic
All checks were successful
Lint / check-sync (push) Successful in 8s
Lint / check-sync (pull_request) Successful in 7s
183973f2fc
Author
Collaborator

🤖 Review — COMMENT (automated)

CI for 183973f2: success

Recovery 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" plus git 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.
  • Unverifiable from the diff: the lock itself. The entire fix depends on forgejo_find_claimable refusing 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.
  • Unverifiable from the diff: forgejo_my_assigned open-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. If forgejo_my_assigned returns closed issues too, every merged issue picks up an orphan comment + git worktree remove on 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.
  • Operator direction deviation (staleness threshold) — argued, not implemented. The issue's amendment asks for "assigned + no PR + no worktree activity beyond a staleness threshold". You substitute the global flock argument, and I think it holds (the sweep and the claim→PR sequence are serialized under the same lock, so "assigned, no PR" can only be a crashed predecessor). Flagging only because it's an explicit deliverable resolved by reasoning rather than code; the reasoning is in the PR body, which is the right place.

Test notes

  • The recovery_split cases are genuine logic tests against the real checkpoint_count_non_wip — good.
  • The wiring checks are grep-for-source-text (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_LINE both key on a column-0 ^fi$ / ^else$ after a log line; 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 from NOOP_ELSE_LINE to EOF, so it would pass on an unassign from any later path (block, checkpoint requeue), not specifically the noop branch.
  • Nothing covers a merged PR in history (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.

### 🤖 Review — `COMMENT` _(automated)_ CI for `183973f2`: **success** Recovery 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"` plus `git 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. - **Unverifiable from the diff: the lock itself.** The entire fix depends on `forgejo_find_claimable` refusing 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. - **Unverifiable from the diff: `forgejo_my_assigned` open-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*. If `forgejo_my_assigned` returns closed issues too, every merged issue picks up an orphan comment + `git worktree remove` on 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. - **Operator direction deviation (staleness threshold) — argued, not implemented.** The issue's amendment asks for "assigned + no PR + no worktree activity beyond a staleness threshold". You substitute the global flock argument, and I think it holds (the sweep and the claim→PR sequence are serialized under the same lock, so "assigned, no PR" can only be a crashed predecessor). Flagging only because it's an explicit deliverable resolved by reasoning rather than code; the reasoning is in the PR body, which is the right place. ## Test notes - The `recovery_split` cases are genuine logic tests against the real `checkpoint_count_non_wip` — good. - The wiring checks are grep-for-source-text (`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_LINE` both key on a column-0 `^fi$` / `^else$` after a `log` line; 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 from `NOOP_ELSE_LINE` to EOF, so it would pass on an unassign from any later path (block, checkpoint requeue), not specifically the noop branch. - Nothing covers a merged PR in history (`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. --- <sub>Independent review by the harness on `claude-opus-5` (effort: high). The human reviewer is requested once Igor has reviewed; a human still merges.</sub> <!-- review sha=183973f2fc2a32a87ee3a63270bc6879666e703c verdict=COMMENT ci=success -->
igor added spent time 2026-08-09 22:25:06 +00:00
2 minutes 5 seconds
Author
Collaborator

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_assigned queries state=open only, 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.

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_assigned` queries `state=open` only, 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.
igor merged commit 60a8d76839 into master 2026-08-09 22:27:02 +00:00
igor deleted branch agent/496-fix-claim-gate-re-claims-issues-with-open-ready 2026-08-09 22:27:02 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No assignees
1 participant
Notifications
Total time spent: 2 minutes 5 seconds
igor
2 minutes 5 seconds
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
joshtronic/igor!499
No description provided.