Skip to content

XERK-540: don't reclaim a stranded ticket spawn a fresher Start superseded - #620

Merged
xerhab merged 1 commit into
mainfrom
XERK-540
Sep 1, 2026
Merged

XERK-540: don't reclaim a stranded ticket spawn a fresher Start superseded#620
xerhab merged 1 commit into
mainfrom
XERK-540

Conversation

@xerhab

@xerhab xerhab commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

What

Fixes XERK-540: a manual spawnTicket stranded undelivered on an offline host could transiently double-start one ticket.

The sequence (all in turma/server.js's in-memory ticket queue):

  1. A manual spawnTicket for a ticket is stranded undelivered on host A, which goes offline (>OFFLINE_AFTER_MS = 75s silent).
  2. Operator clicks Start. committedTicketSpawn (XERK-331) deliberately returns null for an undelivered-on-offline command (reclaim owns that case), so a fresh spawn dispatches to a live host B.
  3. On a later 15s sweep, reclaimStrandedTicketSpawns withdrew A's command and re-queued the ticket as a manual entry; drainTicketQueue dispatches manual entries unconditionally (the guard-skip that lets the board's + button ask for a second session) → a second live session for the one ticket.

Per the ticket, this is a design call: the fix must tell "operator asked for a second session" (the + button — must keep working) apart from "reclaim re-queued a command whose fresh start already happened," without regressing the manual guard-skip.

Why / how

Took the ticket's suggested route — have reclaim consult the dispatch memo the way the DELETE cancel route does — made precise with a cmdId:

  • ticketDispatchedAt now stores {at, cmdId} instead of a bare timestamp. rememberDispatch(site, key, cmdId) records the cmdId at every dispatch site (both Start-route paths + drainTicketQueue), so the memo always names a ticket's newest dispatch.
  • New dispatchSupersedes(site, key, cmdId) → true when the newest dispatch's cmdId differs from a stranded command's own cmdId (within TICKET_DISPATCH_MEMO_MS = 5 min).
  • reclaimStrandedTicketSpawns checks it before its repo/free-host/backoff preconditions: a superseded stranded command is withdrawn but not re-queued (cleaning it off the dead host so it can't start on return, turning a transient double-start into a lasting one); a genuinely stranded command — whose own dispatch is still the newest (memo cmdId == its own) — re-routes exactly as before.

This is precise: a plain reclaim leaves the memo's cmdId equal to the command's own, so nothing changes; only a genuine second dispatch (a fresh Start) trips the guard. The + button is untouched (it acts when no in-flight command exists → committedTicketSpawn null → fresh dispatch). The cancel route's dispatchedRecently still reads rec.at off the new shape.

How verified

  • Two new XERK-540: cases in server.test.js (superseded → withdraw-not-requeue; ordinary → still reclaimed). Full turma suite green: 1699 pass, 0 fail, including the whole XERK-303: reclaim block.
  • Rule doc updated (.claude/rules/turma-ticket-queue.md), under the size cap.
  • QA agent verdict: PASS. Booted the real hub (clean, GET /api/agents 200); ran the suites; mutation-tested the guard (neutering dispatchSupersedes→false fails the double-start test; dropping the cmdId !== guard fails both ordinary-reclaim and XERK-303 basic reclaim — both caught); adversarial scenarios incl. two sequentially-stranded hosts (the newest dispatch always survives fleet-wide → exactly one session) and a genuine +-button second session (not regressed). Delivered commands never reach the check (it sits after the "deliveredAt" in c continue). No defects.

Pure server-side queue plumbing (turma/server.js only) — parity-exempt per the Web ⇄ Android contract.

…seded

A manual spawnTicket stranded undelivered on an offline host could be
started a second time and reclaimed a third, transiently double-starting
one ticket:

  1. committedTicketSpawn (XERK-331) correctly lets a fresh Start through
     for an undelivered-on-offline command (reclaim owns that case), so a
     new session comes up on a live host.
  2. reclaimStrandedTicketSpawns then re-queued the stranded command as a
     MANUAL queue entry, and drainTicketQueue dispatches manual entries
     unconditionally (the guard-skip that lets the + button ask for a
     second session) — a second live session for the one ticket.

Fix: the dispatch memo (ticketDispatchedAt) now records each dispatch's
cmdId, not just its timestamp. rememberDispatch stamps it at every
dispatch site (both Start paths + drainTicketQueue), so the memo always
names the NEWEST dispatch. reclaim consults dispatchSupersedes: when a
stranded command's cmdId is not the newest dispatch (a fresher Start
routed the ticket elsewhere within TICKET_DISPATCH_MEMO_MS), it withdraws
the superseded command WITHOUT re-queueing — cleaning it off the dead host
so it can't start on return, while the fresh dispatch remains the one
session the operator asked for. A genuinely stranded command (its own
dispatch is still the newest) is re-routed exactly as before.

Distinguishes 'operator asked for a second session' from 'reclaim
re-queued a command whose fresh start already happened' without regressing
the manual guard-skip.

Tests: two XERK-540 cases in server.test.js (the superseded-withdraw and
the ordinary-still-reclaimed paths).
@xerhab
xerhab merged commit 96c2e72 into main Sep 1, 2026
5 checks passed
@xerhab
xerhab deleted the XERK-540 branch September 1, 2026 20:27
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