Skip to content

fix: make a volunteer claim mean the same thing on both paths (#6112) - #6149

Merged
atomantic merged 3 commits into
mainfrom
claim/issue-6112
Sep 4, 2026
Merged

fix: make a volunteer claim mean the same thing on both paths (#6112)#6149
atomantic merged 3 commits into
mainfrom
claim/issue-6112

Conversation

@atomantic

Copy link
Copy Markdown
Owner

Summary

Two code paths resolved the same event — a human commenting "taking this" on an unassigned issue — and they wrote opposite forge state. issueWatcher.js#assignVolunteer set the assignee, stamped in-progress, and left the good first issue / help wanted invitations up; the claim agent's Phase 1 handoff was instructed to do exactly the reverse ("leave contributor-invitation labels intact, do NOT add in-progress"). Whichever ran first decided what the issue looked like afterwards.

This takes option (A) from the issue — a volunteer claim IS a claim — and encodes it once.

One policy, one place. volunteerClaimLabels() in server/lib/dispatchLabels.js states it (in-progress on, both contributor invitations off), beside the existing formatContributorLabelReleaseCommands. The issue-watcher reads the label lists directly; the claim prompt renders its shell commands from formatVolunteerClaimCommands(), the way CONTRIBUTOR_RELEASE_GH is already interpolated — so the prompt and the deterministic path cannot drift again. The rendered handoff includes the lazy label create before the --add-label (a fresh fork would otherwise 422 and silently drop the marker), and a failed or unverified handoff still writes nothing at all.

The marker finally has a releaser. in-progress had three appliers and two releasers: a volunteer assigned but never opening a PR matched issueReconcile.js#classifyIssue's STALLED on every pass, indefinitely. The classifier gains a narrower ABANDONED state — a non-owner claim with no merged PR, no open PR, no claim branch, no active agent, untouched for FOREIGN_CLAIM_STALE_DAYS (14) — and releaseAbandonedClaims() strips the label and the stale assignee with an explanatory comment, returning the issue to the queue. It is deliberately conservative:

  • An unresolved viewer login (a gh auth blip) means hasForeignClaim is false for everything — absent is never "everyone is a foreigner".
  • An absent/unparseable update time never satisfies the day gate — absent is not zero.
  • A claim held by this install is never touched; its release is owned by the claim flow's Phase 6/7 and by the zombie heal, and racing them could unassign a run live on a peer.
  • GitLab/JIRA resolve no owner login on purpose, so they never classify ABANDONED — both flows that create a volunteer claim are gh-only, so there is no PortOS applier there to release.

The release runs deterministically in the issue-reconcile pre-step rather than through the coordinator agent (the facts already are the decision, so an LLM call would only re-derive them), and it runs before the "no zombies → park" return — a repo whose only stuck issues are abandoned claims has zero zombies on every pass.

Also folded in (the issue's "Related" note): layeredIntelligence/forgeFiler.js#applyBlockingLabel is the only path that applies LI_BLOCKING_LABEL and had no lazy label create, so the first pause on an install that had never filed an issue would have hit the same latent 422. It now calls ensureForgeLabels first. The broader four-call-site applyForgeLabel consolidation is not included — the four callers use three different exec conventions, so it is not the ~10 lines the issue estimated and would have doubled this diff.

Distribution-model bookkeeping: PROMPT_VERSIONS['claim-issue'] 24 → 25, the outgoing v24 body preserved in PREVIOUS_DEFAULT_PROMPTS, the integrity snapshot regenerated, and scripts/migrations/338-claim-issue-volunteer-claim-contract.js upgrades un-customized stored prompts on existing installs.

Test plan

  • cd server && npm test — 1920 files / 38765 tests passing.
  • cd client && npm test — 834 files / 10246 tests passing (untouched by this change; run to confirm).
  • New/updated coverage:
    • server/lib/dispatchLabels.test.js — the policy shape, that it hands back a mutable copy rather than the frozen shared list, and the create → add → release command order for both gh and glab.
    • server/services/issueWatcher.test.js — the full marker set on the fast path and on the label-create retry path, plus a new case proving a --remove-label 422 (an issue carrying neither invitation, the common case) does not take the assignment down with it.
    • server/services/issueReconcile.test.jsABANDONED vs STALLED across the threshold, a null stale-day count, an owner-held claim, an unresolvable viewer login, and GitLab; releaseAbandonedClaims comment/edit ordering, the comment-fails-release-still-happens path, the edit-fails-retry-next-pass path, and the no-write guards; plus a guard that the identity probe is skipped when nothing is assigned.
    • server/services/cosTaskPreStepBlocks.test.js — the release is ordered after the null guard and before every parking/dispatch return.
    • server/services/taskPromptDefaults.test.js — v25 renders the shared helper's exact commands, both halves of the old contract are gone, and the outgoing v24 body is preserved.
    • scripts/migrations/338-...test.js — upgrades a stored v24 default in both schedule locations, leaves customized and already-current prompts alone.
  • Local reviewer (ollama, gemma-4-12B-coder, effort low): "No findings" on the source diff. The full diff exceeded the model's 32k context, so the review was run over the five source files only.

Closes #6112

https://claude.ai/code/session_01SbKSP9rMnsqZj1iVSRfpYX

atomantic and others added 3 commits September 3, 2026 23:21
A human commenting "taking this" on an unassigned issue was resolved by two
code paths that wrote OPPOSITE forge state. The deterministic issue-watcher
(assignVolunteer) set the assignee, stamped `in-progress`, and left the
`good first issue` / `help wanted` invitations up; the claim agent's Phase 1
handoff was instructed to do exactly the reverse. Whichever ran first decided
what the issue looked like afterwards.

Both now write one policy — assignee + `in-progress` + invitations retired —
declared once as `volunteerClaimLabels()` in lib/dispatchLabels.js, which the
watcher reads directly and the claim prompt renders its shell commands from
(claim-issue v25). A volunteer claim is a claim: it is what the Issues tab
hides on, and an issue somebody has taken must stop advertising itself to the
next contributor. A failed or unverified handoff still writes nothing.

`in-progress` also gained the releaser it was missing. It had three appliers
and two releasers, so a volunteer who was assigned and never opened a PR
matched the reconciler's STALLED state on every pass, forever. issueReconcile
now classifies that case ABANDONED — a non-owner claim with no merged PR, no
open PR, no claim branch, no agent, untouched for 14 days — and releases the
marker and the stale assignee back to the queue with an explanatory comment.
It is deliberately narrow: an unresolvable viewer identity, an unknown update
time, or a claim held by this install never qualifies, so the release can
never race a run that is live on a peer.

Also fixes a latent 422 in the Layered Intelligence pause path:
applyBlockingLabel is the only caller that applies its label and had no lazy
`label create`, so the first pause on an install that had never filed an issue
would have failed.

Claude-Session: https://claude.ai/code/session_01SbKSP9rMnsqZj1iVSRfpYX
@atomantic
atomantic merged commit c0d3d66 into main Sep 4, 2026
7 checks passed
@atomantic
atomantic deleted the claim/issue-6112 branch September 4, 2026 00:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Reconcile the volunteer-claim contract: claim prompt says do NOT add in-progress, issue-watcher now does

1 participant