Skip to content

feat(MAPCO-11432): count an attempt when a ticket is handed back - #9

Open
razbroc wants to merge 1 commit into
feat/spend-ceilingsfrom
feat/count-attempts
Open

feat(MAPCO-11432): count an attempt when a ticket is handed back#9
razbroc wants to merge 1 commit into
feat/spend-ceilingsfrom
feat/count-attempts

Conversation

@razbroc

@razbroc razbroc commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Stacked on #8 — review that first, and this PR retargets as the stack merges.

Question Answer
Bug fix
New feature
Breaking change
Deprecations
Documentation
Tests added
Chore

This is the attempt-counter half of MAPCO-11432 only. The boot-time orphan release is not here. It is split out because both MAPCO-11434 and MAPCO-11435 are blocked on it: each defines a port (ReleasePort, AbortPort) whose contract is "release and count", and nothing in the repository could write that label.

Adds JiraPort.setLabels and handBackTicket — the one binding both ports are meant to use, so there is one implementation rather than two that drift.

Why the counter is load-bearing

buildPollQuery filters on agent-ready, an empty assignee, and the label at ATTEMPT_CAP. So a ticket handed back without a bumped counter matches the poll again on the very next tick, is claimed again, and is paid for again — one ticket able to eat the whole daily allowance. ATTEMPT_CAP cannot express "tried twice" unless something writes it down, and until this PR nothing could.

⚠️ Count first, then release — the reverse of how it reads

Unassigning is what makes a ticket visible to the poll, and it is the last thing releaseTicket does. So the counter has to already be on the ticket before that happens. Counting afterwards leaves a window where the ticket is available and uncounted, and if the count then fails the window never closes.

Which means a failed label write stops the hand-back entirely. The ticket stays held by the bot and In Progress, which the poll query skips, and the boot-time orphan sweep is what recovers it. Held-and-uncounted is recoverable; available-and-uncounted is a re-burn loop. There is a test asserting the write order is exactly labels → comment → transition → assign, and another asserting that a failing label write produces no Jira writes at all.

releaseTicket itself is unchanged and still does not count — it is also the release used by paths that must not, like the hand-straight-back in runCycle, which does no work and spends nothing.

Two sharp edges in the label write

setLabels overwrites the whole set. That is what jira_update_issue offers, and the caller has to have read the existing labels anyway to compute the next counter. The consequence: anything absent from the array is removed, so a caller must pass what it wants kept. countAttempt preserves every non-counter label, agent-ready included — enrolment is a human's decision and an overspend is not a reason to revoke it.

countAttempt clamps at the cap. Writing agent-attempted-3 on a ticket already at a cap of 2 would not tighten anything — the query excludes the label exactly at the cap, so a higher number would make the ticket poll-visible again and hand it straight back to the worker for ever. A ticket at the cap is already invisible, so leaving its counter where it is loses nothing.

fields is a JSON string, like assigneeFields — passing an object updates nothing and still reports success, which is why it is its own named, tested function.

Acceptance criteria (MAPCO-11432)

  • Each release increments an attempt count stored as a Jira label, and nowhere else
  • After 2 attempts the poll no longer returns the ticket (the query already did this; this PR is what makes the label reachable, so the filter now has something to filter on)
  • Give-up tickets are findable as a group by one JQL query, documented with the label convention. The convention is documented in constants.ts and attempt.ts; the operator-facing JQL is not in the README yet.
  • On process start, tickets assigned to the worker and still In Progress are always released with a comment. Not in this PR — the boot sweep is the other half of MAPCO-11432.
  • Seam test: a boot that finds an orphaned In Progress ticket releases it instead of resuming. Blocked on the above.
  • Seam test: hitting the attempt cap stops the ticket being picked up

Follow-up

The boot-time orphan release is now unblocked — it needs the sweep at process start plus a seam test, and this PR gives it the label write it depends on.

Refs: MAPCO-11432

Adds `JiraPort.setLabels` and `handBackTicket`, the one binding both `AbortPort`
(MAPCO-11435) and `ReleasePort` (MAPCO-11434) are meant to use.

The attempt counter is the only thing that ends a loop. The poll filters on
agent-ready, an empty assignee and the label at ATTEMPT_CAP, so a ticket handed
back without a bumped counter matches again on the next tick, is claimed again
and is paid for again. Nothing could write that label before this.

Counting happens before releasing, which is the reverse of how it reads.
Unassigning is what makes a ticket visible to the poll and it is the last thing
releaseTicket does, so the counter has to already be there. A label write that
fails therefore stops the hand-back: held and In Progress is recovered by the
boot-time orphan sweep, whereas available and uncounted is a re-burn loop.

`setLabels` overwrites the whole set, so callers pass what they want kept.
`countAttempt` clamps at the cap — writing past it would make the ticket
pollable again rather than tightening anything.
@github-actions

Copy link
Copy Markdown

🎫 Related Jira Issue: MAPCO-11432

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.

1 participant