Skip to content

feat(MAPCO-11434): implement claimed tickets instead of handing them straight back - #19

Open
razbroc wants to merge 2 commits into
feat/branch-commit-prfrom
feat/wire-implement-into-cycle
Open

razbroc wants to merge 2 commits into
feat/branch-commit-prfrom
feat/wire-implement-into-cycle

Conversation

@razbroc

@razbroc razbroc commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Stacked on #10 — 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 closes existing acceptance criteria on MAPCO-11434 and MAPCO-11433 — it is not new scope. Both tickets are already Waiting for PR with the relevant criteria unmet. No new ticket was filed; if you would rather see the wiring tracked separately, it is a Task under MAPCO-10721, but the work below is 11433's and 11434's.

Ticket Criteria this closes
MAPCO-11434 "The ticket is handed to the Agent SDK as the task, working inside the clone" · "Repeated test failure … takes the existing release path"
MAPCO-11433 "The repo is cloned at its default branch into an ephemeral workspace in the container" · "The workspace is cleaned up on every path"

What this is

runCycle claimed a ticket and handed it straight back. Separately, a fully tested implement-and-verify core sat next to it that nothing called. handleTicket now calls it, between the claim and the release — so labelling a ticket agent-ready runs the whole thing rather than proving the claim/release plumbing works.

Poll → claim (within the day's allowance) → resolve the repo from the title → clone it → hand the ticket to the model inside the clone → run the repo's own tests → push an agent/ branch and open a PR → clean up. Anything that stops short is a hand-back: a comment, the attempt counter bumped, the ticket back in Open.

Base branch

feat/branch-commit-pr (#10), because it is the only tip carrying handBackTicket (#9), the budget guard (#8) and publishPullRequest (#10) together, and both ReleasePort and AbortPort say in as many words to bind to handBackTicket rather than write a second release path.

That means this does not include #13's model-credential rework. #13 replaces apiKey.ts with credential.ts and adds its own helm block for the auth modes; the ANTHROPIC_API_KEY secretKeyRef added here is the #10-leg equivalent, so that block is the expected conflict when #13 rebases. Nothing in cycle.ts or worker.ts depends on which of the two is underneath — the credential seam is inside createSdkAgent.

⚠️ Two blockers had to be closed, not deferred

DescriptionPort had no implementation. The honest stub answered '', and implementTicket refuses an empty description before the first model turn — so every agent-ready ticket was claimed, refused as no-description and handed back. That is why labelling a ticket proved only the plumbing.

createDescriptionReader reads the prose per claimed ticket through JiraPort.getIssue, whose field list now asks for it. POLL_FIELDS deliberately still does not, which is a change from the handoff's suggestion: the poll asks for one more ticket than it will work, so a description there is paid for on every tick for tickets nobody touches — and that is the DescriptionPort docstring's own argument, so putting it in POLL_FIELDS would have contradicted a docstring in the same PR. The consequence worth knowing: an absent description and an empty one are now different facts, and only the second is a refusal.

Nothing produced the workdir. implementTicket and CliGit both take one and no clone code existed, so MAPCO-11433's criterion was unmet and no end-to-end run was possible.

CloneWorkspace makes one shallow single-branch clone per ticket in a mkdtemp directory and removes it in a finally — including on the publish failures that throw by design. Three things about it are deliberate:

  • The branch is GitHub's default_branch, not the remote's HEAD. The org has both master and main; a checkout that disagreed with the PR's base would diff against the wrong one. tests/unit/vcs/clone.spec.ts asserts this against a real bare repo whose HEAD is main and whose work is on release — a fake that agreed with us about --branch would prove nothing.
  • The credential goes through the environment and a helper, never argv, reusing feat(MAPCO-11436): worker builds the branch, commit and PR in code #10's CREDENTIAL_FROM_ENV. The shared plumbing is extracted to vcs/gitInvoke.ts so the clone and the push cannot drift apart; cliGit.ts's public surface is unchanged and its real-git scratch-repo spec passes untouched.
  • Only https://github.com gets a credential. The helper answers for whatever host git asks about, so an unvetted remote is a live token handed to it. file: is allowed and deliberately credential-free.

The spend ceilings now bite, and that needed two decisions

meterAgent wraps the agent per ticket and charges every hand-off to the ledger. Charging once after implementTicket returns cannot work: by then the ticket is either already handed back or has a verified change waiting, so the ceiling could only be enforced in hindsight and one ticket could spend ten times it before anything noticed.

  • Turns are charged from the run's own num_turns, now carried on AgentRun. TicketLedger.charge's docstring warns about exactly this: charging 1 per hand-off would make MAX_TURNS_PER_TICKET=40 mean forty hand-offs of forty turns apiece. This is the one change to a merged slice's public type in this PR.
  • Cache reads count against MAX_TOKENS_PER_TICKET. They are billed prompt tokens and an agentic loop re-reads its whole context every turn; excluding them would put most of what a ticket costs outside the ceiling that exists to bound it.

cycle.runLine() replaces the run line's hard-coded tokensSpent: 0, and the guard is opened per cycle inside runCycle — so the spend resets per line while the daily counter does not. There is a test for exactly that, because the alternative was the documented defect.

⚠️ Two give-up paths, one hand-back

Wiring the two slices together creates a case neither could see alone. On an overspend, chargeSpend calls AbortPort.abort — which hands the ticket back — and the run it stopped is then reported to implementTicket as a give-up, which hands it back again: a second comment and a second transition on a ticket already in Open that a human may have picked up. BudgetExhausted.alreadyStopped's docstring describes this hazard and cannot prevent it; its latch stops repeated charges, not a different code path releasing.

createHandBackOnce is a per-ticket latch that both ports bind to. The first caller writes; every later one is told what the first achieved. The overspend note wins, which is the right way round — it says what the ticket cost and which ceiling it hit, where the give-up note would only say the run did not finish. It stays open when the first attempt wrote nothing at all (attemptCounted: false means handBackTicket refused before its first write), because a silently held ticket is worse than a second try.

Lifetimes

createImplementer is two calls now, because collapsing them was not viable: the agent must be built once at boot, where the model credential is read, so a pod with no Secret fails to come up rather than claiming a ticket to discover it — while the hand-back belongs to one ticket, because the latch does. src/worker.ts is the single place those lifetimes are decided and has a spec asserting them.

runCycle also now hands a held ticket back when something throws part-way. The existing paths keep hold of such a ticket on the argument that the boot-time orphan sweep recovers it — but that sweep is MAPCO-11432 and does not exist, so the ticket would stay assigned to a bot for ever. A ticket the worker never managed to claim is still never written to.

Deployment

The image gains git. The chart gains the workspace emptyDir, the two credentials, the three budget knobs, the commit identity, and resources sized for a pod that now runs npm ci and a test suite — the previous 100m/128Mi was right for a Jira poller and would OOM-kill on the first install.

Verification

npm ci && npm test && npm run lint && npx tsc --noEmit && npm run build

37 files, 468 tests, all green; coverage 88.7% statements / 87.0% branches, up from 87.0/86.1. prettier --check and eslint clean.

helm lint passes with --set mclabels.component=backend. Without it, it fails on mclabels.component: worker, which is not a member of the subchart's enum — a pre-existing defect on this leg of the stack, fixed on #13 (fix: align the mclabels block with the org's service charts). I deliberately did not fix it here, so as not to duplicate that commit and conflict with it. The chart renders valid YAML both with and without the optional secrets.

Two specs drive the real git binary against real repositories, following the precedent scratchRepo.spec.ts set.

Deliberately still open

  • The commit stages every changed path. AgentRun reports whether the tree changed, not what changed, so publishPullRequest gets UNREPORTED_WRITESfeat(MAPCO-11436): worker builds the branch, commit and PR in code #10's own designed-for state. Bounded (status --porcelain never reports an ignored file) and stated in the PR body and the log, but a repo that does not gitignore its build output gets a noisier diff than a human would have committed. Forwarding the paths the SDK already carries is what removes the branch; it is not this PR's scope.
  • Only Node repositories can be verified. MAPCO-11433 also asks for Python (pytest). TestRunner is a port so that is an implementation rather than a reshape, but it does not exist and a Python ticket is refused as not-verifiable. MAPCO-11433 is therefore not fully closed by this PR — its clone and cleanup criteria are, its language-inference criterion is not.
  • GITHUB_TOKEN is a static token, not MAPCO-11428's per-run App token. vcs/envToken.ts exists to be deleted; everything around it already treats the credential as short-lived. It is checked at boot rather than per ticket, because a missing credential discovered per ticket costs a claim, a comment naming an env var, and a counted attempt on every ticket — and two of those retire a ticket from the queue.
  • A ticket whose PR is open stays assigned to the bot. The work is done and a human is reviewing; releasing it would put it back in the poll's way. No ticket asks for a status transition here, and STATUS_NAMES has no Waiting for PR, so none is attempted.
  • The Agent SDK on Alpine/musl is unverified. The SDK ships vendored binaries (ripgrep, backing Grep) that are usually glibc-linked. A failing Grep degrades a session rather than breaking it — the model still has Glob and Read — but it is worth checking in the first pod logs.

MAPCO-11434 and MAPCO-11433. `runCycle` claimed a ticket and handed it straight
back; a fully tested implement-and-verify core sat next to it that nothing
called. `handleTicket` now calls it, between the claim and the release, so
labelling a ticket `agent-ready` runs the whole pipeline: resolve the repository
from the title, clone it at its default branch, hand the ticket to the model
inside that clone, run the repository's own tests, and on a passing run push an
`agent/` branch and open a pull request.

Two things had to exist first, and both were blockers rather than polish.

`DescriptionPort` had no implementation, so the honest stub answered `''` for
every ticket and `implementTicket` refused all of them before the first model
turn — which is why labelling a ticket proved only that the claim and release
plumbing worked. `createDescriptionReader` reads the prose per claimed ticket
through `JiraPort.getIssue`, whose field list now asks for it. `POLL_FIELDS`
deliberately still does not: the poll fetches one more ticket than it will work,
so prose there is paid for on every tick for tickets nobody touches. An absent
description is therefore a different fact from an empty one, and only the second
is a refusal.

Nothing produced the `workdir` that `implementTicket` and `CliGit` both take, so
MAPCO-11433's clone criterion was unmet and no end-to-end run was possible.
`CloneWorkspace` makes one shallow, single-branch clone per ticket in a
`mkdtemp` directory and removes it in a `finally`, including on the publish
failures that throw by design. The branch is GitHub's `default_branch` and not
the remote's HEAD: the org has both `master` and `main`, and a checkout that
disagreed with the pull request's base would diff against the wrong one. The
credential reaches git through the environment and a helper, never argv, and
only for `https://github.com` — the helper answers for whatever host git asks
about, so an unvetted remote is a token handed to it.

The spend ceilings now bite. `meterAgent` wraps the agent per ticket and charges
every hand-off to the ledger, which is what makes `MAX_TOKENS_PER_TICKET` an
in-flight bound rather than a retrospective one; charging once after
`implementTicket` returned could only ever notice an overspend after the money
was gone. Turns are charged from the run's own `num_turns`, now carried on
`AgentRun`, because charging one per hand-off would have made a ceiling of forty
turns mean forty hand-offs of forty turns apiece. Cache reads count, because
they are billed prompt tokens and an agentic loop re-reads its context every
turn. `cycle.runLine()` replaces the run line's hard-coded `tokensSpent: 0`.

That leaves two give-up paths able to fire on one ticket — the implement loop's
and the budget guard's — and an overspend takes both: the abort hands the ticket
back, and the run it stopped is then reported as a give-up, which would comment
again on a ticket already in Open that a human may have picked up.
`createHandBackOnce` is a per-ticket latch that both bind to, so the first
caller writes and every later one is told what it achieved. It stays open when
the first attempt wrote nothing at all, because a silent held ticket is worse
than a second try.

`createImplementer` is two calls now. The agent must be built once at boot,
where the model credential is read, so a pod with no Secret fails to come up
rather than claiming a ticket to discover it; the hand-back belongs to one
ticket, because the latch does. `src/worker.ts` is the one place those lifetimes
are decided, and `src/index.ts` and `src/dryRun.ts` differ only in what they do
with the result.

`runCycle` also hands a held ticket back when something throws part-way. The
existing paths keep hold of such a ticket on the argument that the boot-time
orphan sweep recovers it, but that sweep is MAPCO-11432 and does not exist, so
the ticket would stay assigned to a bot for ever.

The chart gains the workspace `emptyDir`, the two credentials, the budget knobs
and the commit identity, and resources sized for a pod that now runs `npm ci`
and a test suite rather than one that polls Jira. The image gains `git`.

Still open, and deliberately: the commit stages every changed path because
`AgentRun` reports whether the tree changed and not what changed; only Node
repositories can be verified, so MAPCO-11433's Python criterion is unmet; and
the GitHub credential is a static `GITHUB_TOKEN` rather than the per-run App
token MAPCO-11428 specifies — checked at boot, because discovering it per ticket
would cost a claim, a comment and a counted attempt on each one.
@github-actions

github-actions Bot commented Sep 15, 2026

Copy link
Copy Markdown

🎫 Related Jira Issue: MAPCO-11434

MAPCO-11433 asks for a workspace cleaned up on every path, and one path did
not. `create` made the directory with `mkdtemp` and only then minted the push
credential, with both calls above the `try` whose `catch` removes the
directory. `mint` throws on a missing `GITHUB_TOKEN`, so that throw left an
`agent-ticket-*` directory behind with nothing holding a reference to it:
`deliver` turns the failure into a `no-workspace` hand-back and never receives
a `Workspace` to dispose. One leak per ticket fills the volume and then fails
every ticket for a reason that looks like something else.

The mint moves above `mkdtemp` rather than inside the `try`, because the
`catch` redacts the failure message with the token and so needs it in scope
first. It also keeps the invariant already stated at the top of `create`
literally true — refused before the directory is made and before the token is
minted — so a credential the worker cannot produce now costs nothing and
leaves nothing, exactly as an unvetted remote already did.

Two docstrings referred to a `deliverTicket` that does not exist; the module
exports `createDelivery` and a `DeliveryPort.deliver`. `Assignment`'s was also
wrong about the work: it credited one function with both the claim and the
clone, where `handleTicket` claims and `deliver` resolves and clones.
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