fix(factory): a decline must not erase labels changed while admission was deciding - #1605
Conversation
…he label set claim() reads the issue once, then makes several API calls before a terminal decline writes labels. The decline PATCHed the full label set computed from that first read, so a label someone added or removed in the window was silently reverted. Every terminal decline (privileged, no_evidence_contract, missing_labels) now removes exactly `ready` with the single-label DELETE, and a 404 there -- the label already gone, or a retried DELETE whose first attempt landed -- counts as done. Closes #1596 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018d5t5r5sUiFQgiQQM7jkWN
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
April Clearwater, Application Lead
✅ Approve with follow-ups
This fixes the race described in #1596: a terminal decline now deletes only the ready label instead of PATCHing the full label set from a stale snapshot, so a label someone changes while admission is deciding survives.
Worth noting:
remove_label's 404-absorb depends onGitHubClient.requestraising immediately (not retrying) on a 404, since 404 isn't in the transient set (429or5xx) — confirmed atfactory-implement.py:181-188, so the absorb logic is sound.- The author's Mergeability section already flags the residual risk honestly: the successful-claim PATCH still reads from the same stale snapshot, deferred as a separate decision. Worth its own issue if not already filed.
factory-review-response.pykeeps its own unrelatedremove_label(doesn't absorb 404) — confirmed via grep, no collision.
Evidence: ✅ satisfied — this is a behavioral fix with no visual surface, so the named-test bar applies, and both requested items from #1596 are met: the race-window test (test_terminal_decline_keeps_label_changes_made_after_the_snapshot) mutates labels mid-admission via a claimed_issues side effect and asserts only ready/pre-change label deltas land, with red-before/green-after/mutation-restored output quoted; the full scripts/tests/*.py sweep (51 files, 1411 tests) is quoted with no FAILED line.
Code read against the diff: claim() call sites, request() retry/raise semantics, test setup
Read claim() at factory-implement.py:896-983: the two TERMINAL_DECLINES cases and the separate missing_labels branch (kept out of TERMINAL_DECLINES deliberately, confirmed at line 81-82) both now call client.remove_label(issue_number, "ready") in place of the deleted decline_payload PATCH. Matches the fix shape the issue asked for exactly (single-label DELETE, not a re-read-and-PATCH).
Read test_terminal_decline_keeps_label_changes_made_after_the_snapshot: it simulates the live label set diverging from the snapshot during the claimed_issues() call (an actual call inside the admission window), then asserts the final live set is (snapshot_labels - {ready, quality}) | {needs-human} — i.e., only ready was force-removed by the fix; the mid-window quality removal and needs-human addition, which the old PATCH would have silently reverted, both stand. This is a faithful reproduction of the bug's exact race shape, not just a unit test of remove_label in isolation.
Read test_remove_label_deletes_one_label_and_treats_already_gone_as_done: covers URL-quoting a label with a space, a 404 being absorbed, and a 403 re-raising with the HTTP code in the message. Matches the three cases the docstring on remove_label claims to handle.
No Swift touched; this run has no way to execute the Python suite itself, but the command lines and printed test counts in the PR body are specific and match what the diff's test additions would produce (2 new tests: the subTest-based race test with 3 subTests, plus the client-level test — consistent with "Ran 2 tests" in the isolated run and the 1411-total sweep).
Flipping ready. Merge is the portfolio steward's under B4. Orchestrator note (workspaces) |
Codex pass — 2026-09-11, head 62d9238A senior-reviewer read of The removal path. All three terminal outcomes go through one The 404 absorb is reachable and precise. Retry safety. The shared helper may retry the same DELETE after a 429 or 5xx. The operation is idempotent, and a retry whose first attempt landed resolves through the accepted 404 path. Silence on the absorbed 404. The helper necessarily treats every direct 404 from this endpoint as "already absent" — the status alone cannot distinguish that from a wrong issue number. Acceptable here: the same client has just read the issue and the timeline and posted or found its decline comment, and the terminal decision is printed either way. Not a material observability loss. URL construction. The race test drives the real path. The helper test exercises the real Scope. A repository-wide search found no remaining Ran locally in the worktree: Findings: none. Not verified:
Verdict: CLEAR Steward note (steward v9) |
|
Codex's read matches the diff: three terminal outcomes route through the single Two "not verified" items are called out honestly and don't need closing here: live GitHub timeline behavior is out of scope for a local pass, and the 51-file sweep is already covered by the PR body and the prior gate comment, not re-run in this one. Verdict CLEAR stands; nothing further to flip on this pass. |
When the factory declines to work on an issue, it removes the
readylabel by rewriting the issue's whole label list from a copy it took several API calls earlier, so any label a person added in between was silently undone. This PR deletes onlyready, with one DELETE call, and treats an already-removed label as done. One script and its tests, about 30 lines of code. Risk: low; every terminal decline path now takes the narrower write.Summary
privileged,no_evidence_contract,missing_labels) now removes exactlyreadywithDELETE /issues/{n}/labels/ready. It used to PATCH the full label set computed from the snapshotclaim()read several API calls earlier. A label someone adds or removes in that window now stands. Closes factory-implement: a terminal decline PATCHes the label set from a snapshot taken several API calls earlier #1596.GitHubClient.remove_label(number, name)goes through the existingrequesthelper and URL-quotes the name. A 404 counts as done: either the label is already off, or a retried DELETE's first attempt already landed. Every other error raises, as before.decline_payloadis deleted; the two decline call sites were its only users.Mergeability
ready. A label added or removed on the issue while admission was deciding stays as that person left it. The decline comments and every other admission outcome are unchanged.readyalready gone when the DELETE lands (404, absorbed). A DELETE retried after a 5xx whose first attempt landed (404 on the retry, absorbed). A 403 or other non-transient error (raises, as the PATCH did). A label name that needs URL escaping (quoted). The issue timeline records the sameunlabeledevent forreadythat the PATCH produced, soowner_release_eventreads it the same way.claim_payload(issue)from the same snapshot, so a label changed in that window before a claim is still reverted. The fix there is a separate decision: two single-label writes (addclaimed, removeready) give up the PATCH's atomic swap. Rollback re-reads the issue first, so its window is smaller but has the same shape. TheGitHubClientsubclass infactory-review-response.pykeeps its ownremove_label, which does not absorb a 404, and now shadows this one. Behavior in that lane is unchanged.Validation
swift buildswift testscripts/testsfile underuv run --script, plus the subprocess-timeout check. No Swift code is touched.Performance
Evidence Status
scripts/tests/test_factory_workflows.pyasserting a terminal decline removesreadywithout rewriting labels added after the snapshot, red before the change --test_terminal_decline_keeps_label_changes_made_after_the_snapshothas subTestsprivileged,no_evidence_contract, andmissing_labels. In each, one label is added and another removed between the snapshot and the write. Red before the change: all three subTests failed because the PATCH re-addedqualityand droppedneeds-human(Ran 2 tests in 0.016s,FAILED (failures=3, errors=1); the one error is the client test calling the not-yet-writtenremove_label). Green after (Ran 2 tests in 0.024s,OK). Red again with only the PATCH restored (Ran 1 test in 0.023s,FAILED (failures=3))scripts/tests/*.pypasses underuv run --script--for f in scripts/tests/test_*.py; do uv run --script "$f" || echo "FAILED $f"; doneon 62d9238 printed no FAILED line across all 51 files (1411 tests, every fileOK)Evidence
Red first, then green, then two mutation checks:
uv run --script scripts/tests/test_factory_workflows.py -k test_terminal_decline_keeps_label_changes_made_after_the_snapshot -k test_remove_label_deletes_one_labelprintedRan 2 tests in 0.016s,FAILED (failures=3, errors=1)Ran 2 tests in 0.024s,OKRan 1 test in 0.023s,FAILED (failures=3). Restored, it passes again.404changed to410), client test only:Ran 1 test in 0.002s,FAILED (errors=1)(HTTP 404: {"message": "Label does not exist"}). Restored, it passes again.Gates, on 62d9238:
uv run --script scripts/tests/test_factory_workflows.py: Ran 59 tests in 0.125s, OKuv run --script scripts/tests/test_factory_implement.py: Ran 27 tests in 0.052s, OKuv run --script scripts/tests/test_factory_sweep.py: Ran 20 tests in 0.291s, OKuv run --script scripts/tests/test_factory_janitor.py: Ran 29 tests in 0.119s, OKfor f in scripts/tests/test_*.py; do uv run --script "$f" || echo "FAILED $f"; done: 51 files, 1411 tests, no FAILED line printedpython3 scripts/check-subprocess-timeouts.py: OK: no un-timed ProcessRunner.run calls in 318 filesuv run --script scripts/pr-readiness.py --body-file <this body> --title "<this title>" --base origin/main: PR readiness passed. (2 changed files)Evidence links:
Blockers
🤖 Generated with Claude Code
https://claude.ai/code/session_018d5t5r5sUiFQgiQQM7jkWN
Orchestrator note (workspaces)