Skip to content

XERK-550: hands-off PR auto-merge + ticket close, opt-in per org - #650

Merged
xerhab merged 3 commits into
mainfrom
XERK-550
Sep 2, 2026
Merged

XERK-550: hands-off PR auto-merge + ticket close, opt-in per org#650
xerhab merged 3 commits into
mainfrom
XERK-550

Conversation

@xerhab

@xerhab xerhab commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

XERK-550 — PR & ticket close bottleneck

The auto-ticket fleet auto-starts bug tickets, but throughput still stalled on a human reviewing/merging each PR and then manually closing each ticket (closing a ticket is what auto-stops the session and frees the slot). For orgs that opt in, the hub now takes both steps for the class of tickets it already auto-starts.

What changed

Hub (turma/server.js)

  • New per-org autoMergeOrgs toggle — the sibling of the existing auto-start switch: file-backed (/data/automerge-orgs.json), on the /api/agents payload + its own SSE, POST /api/jira/<site>/automerge. OFF by default — it merges to the default branch with no human review, so it's a deliberate per-project trust decision, independent of the auto-start switch.
  • autoMergeSweep (15s timer, no-op unless opted in) — for a running, idle ticket session that is auto-start-eligible (autoStartContentGate, the same content gate the auto stream uses), queues a mergePr command to the session's host for each PR reading ready === "ready" (green CI + MERGEABLE + no conflict — the flag the agent already computes) and state === "OPEN". Per-PR backoff (autoMergeState); a gh refusal (mergePrResults ok:false — branch protection, review-required, a fresh conflict) marks the PR gaveUp and stops retrying; a silent failure retries to a cap then gives up.
  • autoCloseSweep — once every PR a session opened has landed and ≥1 actually MERGED, moves the ticket to Done (the XERK-138 status write-back, routed to a board-cred host of the org) and kills the session, freeing the slot without waiting out the ~10-min Jira poll. The kill is gated on the Done write being queued (never orphans the ticket).
  • Eligibility is content-only and reuses autoStartContentGate, so it acts on exactly the class the auto stream would start; a cross-check test pins agreement with autoStartSweep. A Done ticket is excluded (moving to Done is the human's abandon/stop gesture).

Agent (agent/hub-agent.py)

  • mergePr command → _merge_pr_async runs gh pr merge --squash --delete-branch <url> (method/branch-delete env-overridable) on a worker thread — off the heartbeat loop (XERK-395), as the manager, not inside a guarded session (the session guard forbids self-merge; the operator's per-org opt-in is the deliberate override). Outcome staged on merge_pr_results, lock-guarded and cleared by identity like spawn_failures. GitHub only for now; a GitLab MR / ADO PR stages a refusal so the hub gives up.

UI (turma/public/org.js) — a second "merge" switch in the header org menu, beside auto-start.

Safety gate — the hub merges only when ALL hold

org opted in · session running, not working, not blocked on a question · PR ready (green CI, mergeable, no conflict) and OPEN · ticket is an actionable, non-held/rejected/duplicate bug in a non-ignored repo, per the org's triage policy · not Done. Sessions run their own QA before opening the PR (repo policy), and green CI is required.

Note: the "bug class" limitation comes from the org's triage policy (excludeTypes) — the same thing that limits auto-start to bugs — not from the ticket type by itself. Enabling auto-merge on an org without a bug-limiting triage policy will auto-merge whatever auto-start would.

How it was verified

  • QA: adversarial qa agent (built/drove the real exported sweeps + agent merge_pr) returned FAIL with 3 findings; all fixed and re-verified:
    1. a Done ticket's PR was still merged → excluded statusCategory === "done";
    2. auto-close orphaned the ticket when the Done write couldn't dispatch → kill now gated on the write being queued;
    3. a DRAFT PR was merge-attempted then gaveUp forever → require state === "OPEN".
      A qa-delta re-run confirms the fixes hold.
  • Tests: 21 hub cases (XERK-550: / automerge route: in server.test.js) incl. the gate cross-check and the 3 regressions; TestMergePr (10) in test_hub_agent.py; the auto-merge switch in org.test.js. Full turma JS suite (1779) green; agent suite green (bar the known environmental TestDshWeb real-port test). Two suite flakes (control WS ... pongs, drain ...) are pre-existing timing tests that flake on clean origin/main too.

Web ⇄ Android parity

The org-menu auto-merge switch shipped on web only; Android ignores the new autoMergeOrgs payload key (unknown-key-safe) and has no switch yet. Tracked in android/PARITY.md; the hub route works from web regardless.

Docs

.claude/rules/turma-board.md (the two sweeps + gate), .claude/rules/agent-prs.md (the mergePr command), android/PARITY.md.

The auto-ticket fleet auto-starts bug tickets but still stalled on a human
reviewing/merging each PR and then closing each ticket (a closed ticket is what
auto-stops the session and frees the slot). For orgs that opt in, the hub now
takes both steps for the class of tickets it already auto-starts.

Hub (turma/server.js):
- New per-org toggle `autoMergeOrgs` (sibling of autoStartOrgs): file-backed,
  SSE + payload, POST /api/jira/<site>/automerge. OFF by default — it merges to
  the default branch unreviewed, a deliberate per-project trust decision.
- `autoMergeSweep`: for a running, idle, auto-start-ELIGIBLE ticket session in an
  opted-in org, queue a `mergePr` command for each PR that reads merge-ready
  (green CI + MERGEABLE + no conflict). Backoff per PR; a gh refusal (branch
  protection, review required, fresh conflict) gives up rather than spinning.
- `autoCloseSweep`: once every PR of the session has landed (>=1 actually
  merged), move the ticket to Done (XERK-138 write-back, routed to a board-cred
  host) AND kill the session directly, freeing the slot without waiting out the
  ~10-min Jira poll.
- Eligibility reuses the same content gate auto-start applies
  (`autoStartContentGate`), so it acts on exactly the bug class the org
  auto-starts and never on a human-started feature session; a cross-check test
  pins agreement with autoStartSweep.

Agent (agent/hub-agent.py):
- `mergePr` command -> `_merge_pr_async` runs `gh pr merge --squash
  --delete-branch` (env-overridable) on a WORKER THREAD (off the beat, XERK-395),
  as the manager (not a guarded session). Outcome staged on `mergePrResults`,
  lock-guarded + cleared by identity like spawn_failures. GitHub only for now; a
  GitLab/ADO url stages a refusal so the hub gives up.

UI (turma/public/org.js): a second org-menu switch beside auto-start. Android
switch deferred with a PARITY.md line (the new payload key is unknown-key-safe;
the hub route works from web).

Tests: 18 hub cases (routes, gate cross-check, both sweeps, backoff/give-up,
ingest, close+kill), 10 agent cases (TestMergePr), 1 org UI case. Docs in
turma-board.md, agent-prs.md, android/PARITY.md.
QA (adversarial) found three reachable holes; all fixed hub-side:

1. A ticket a human moved to Done still had its open PR auto-merged.
   autoStopSweep only QUEUES the kill, so the session reads running for a beat
   and the column-agnostic gate still merged it — landing unreviewed code on a
   ticket the human was abandoning/rejecting. Fix: autoMergeSession excludes
   `statusCategory === "done"` (the column, not the run state, stands it down).

2. auto-close killed the session even when the Done write couldn't be
   dispatched (no board-cred host / agent too old for setTicketStatus),
   orphaning the ticket In Progress with a merged PR and no session forever.
   Fix: the kill is now gated on the Done write actually being queued — else
   stand down and retry next beat, never kill.

3. A DRAFT PR was treated as merge-ready (_merge_ready returns "ready" for a
   green+MERGEABLE draft), dispatched, refused by `gh`, then marked gaveUp
   FOREVER — silently excluding any session that opened a draft. Fix: the sweep
   requires `state === "OPEN"`.

Also corrected the overclaim "never merges a human-started feature": the gate is
content-only (not provenance), so features are kept out only by the org's triage
policy — the same thing that limits auto-start to bugs. Docs (turma-board.md)
now say so plainly.

Tests: 3 new regression cases (Done not merged, DRAFT not merged, no-kill when
Done can't dispatch). afterEach now releases the auto-merge opt-in so server.js's
live sweep interval idles outside each test (hygiene; unrelated to the
pre-existing control-WS/drain timing flakes that also occur on origin/main).
… policy

Auto-merge lands unreviewed code on the default branch, so restrict it to a hard
floor of triage type "bug" in autoMergeSession — on TOP of the shared content
gate. The content gate matches whatever the auto-START stream would start, which
an operator can widen past bugs via the org's triage policy (or leave wide with
no excludeTypes). Now, even an org that auto-starts other types only ever has its
BUG PRs merged + closed hands-off; every non-bug (task/feature/chore/…) PR still
waits for a human. The policy can narrow auto-merge but never widen it past bugs.

Test: a task/feature/chore/improvement/other ticket that is otherwise fully
eligible (actionable, no excludeTypes) gets neither mergePr nor Done+kill, while
the same setup with type "bug" does. Docs (turma-board.md) + the org switch
tooltip say "bugs only".
@xerhab
xerhab merged commit 2b25c16 into main Sep 2, 2026
6 checks passed
@xerhab
xerhab deleted the XERK-550 branch September 2, 2026 06:07
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