Files
mopac-pmo/archive/REPORT-20260829-1800-fleet-unlocks.md
T

6.0 KiB
Raw Blame History

REPORT — fleet unlocks: turn pool (529) + Redmine-claim dispatch (530)

  • date: 2026-08-29T23:00:00Z (lane: dedicated fleet-unlocks lane, off loop rotation)
  • repo: ukrrs/MOPAC harness @ main, pushed through dca9b06
  • baseline: a3d312c → 5 commits, 17 files, +1550/61
  • verification: ./dev.sh check green (build + vet + test, 12 packages ok) on the final tree; pushed HEAD builds standalone
  • Redmine writes during this lane: NONE (all testing against in-process fakes)

Commits (in order, all pushed to origin/main)

commit what
cfdfaf7 529 config knobs: [loop] max_concurrent_turns + [loop.repo_map] (+2 tests)
8b27127 529 turn pool: semaphore + per-repo mutex, scan integration, heartbeat active/pending, fixes a REPORT tmp-file race the pool exposed (+7 tests)
d71bc91 530 journals read primitive: IssueJournals via ?include=journals (+1 test)
82cfb01 530 claim dispatch: claim-before-turn, epoch race resolution, stale sweep, failure/park release (+9 tests)
dca9b06 fixup: pool.go reserve/launch refactor that 82cfb01 calls (see Incident note)

Incident note (transparency)

82cfb01 was pushed with daemon.go staged but the matching pool.go refactor left unstaged — pushed HEAD did not compile for ~4 minutes until dca9b06 landed (verified broken, then verified building after). No other tree was affected; the final HEAD is build+check green.

Part 1 — 529 turn pool

  • [loop] max_concurrent_turns (default 1 = sequential v0; fleet 2-3). Bounded worker pool: channel semaphore caps concurrent turns per scan; tasks that don't fit stay pending and are reconsidered next scan (never dropped, never marked processed).
  • Per-repo mutex: one turn per work repo at a time, different repos in parallel. Repo derived from [loop.repo_map] by task category (Class field) or task-id prefix; unmapped tasks share the harness root (work_root). Slot + repo lock are reserved before claiming/dispatch so a claimed issue always gets its turn immediately.
  • Scan-end barrier: a scan returns only after every dispatched turn settles (dedup consistency, clean --once exits).
  • status.json additions: active_turns (live), pending (per scan), max_concurrent_turns.
  • Bonus fix the pool exposed: concurrent REPORT writes into one directory raced on a shared .tmp name (one rename stole another turn's file); tmp names are now unique per write.

Part 2 — 530 Redmine-claim dispatch

  • [redmine] claim_enabled (default off = single-loop v0), claim_status (default "In Progress"), claimable_status (default "New").
  • On dispatch, BEFORE the turn: PUT status → In Progress + journal note claimed by <vertical>@<host> at <RFC3339>.
  • Race resolution (no CAS in the Redmine API): the first claimant of the current epoch wins. Epoch = journal tail since the last boundary note (a claim released… or # REPORT -… note). Losers see a foreign first claim, clear their own junk claim with a concede note, and skip to the next candidate. Boundaries keep history from blocking future claims.
  • Success → existing [redmine.status_map] keyed on the claim status ("In Progress" → Resolved/Done). Failure/timeout/park → claim released back to New + explanatory note (next scan retries; a release that can't land because Redmine is down is backstopped by the stale sweep).
  • Stale-claim release: claim older than 2× turn_timeout_secs with no REPORT after it → status back to New + release note, swept every scan. Issues with no loop-claim note (humans moving cards) are never touched. Turn timeout 0 disables the sweep.
  • New JSONL events: claim, claim_lost, release.

Tests (18 new, full suite green)

  • pool (5): bound respected (peak concurrency == 2, backlog drains), per-repo serialization (peak == 1 with pool 3), cross-repo parallelism (deterministic rendezvous), repo_map mapping table, heartbeat counters.
  • claim (9): two-loop race exactly-one-wins (both claims interleave via a response-hold in the fake; asserts 1 turn, 1 REPORT, 1 concede), stale release + redispatch, fresh-claim untouched, human-In-Progress untouched, claim-write failure skips candidate (next candidate still dispatched), lost-race skip + concede, turn-failure release + retry, epoch parsing table.
  • writeback (2): journals decode (object-shaped user, note-less entries skipped), concurrent REPORT writes in a shared dir.
  • config (2): pool knobs parse/defaults/validation, claim knobs parse/defaults/validation.
  • Fixed one latent test race surfaced by mid-scan heartbeat beats (TestLoopStatusFileHeartbeat now waits for a completed scan).

Smoke evidence — live daemon, max_concurrent_turns=2, fake Redmine+LLM

Real RunLoop (--once scans), 3 tasks across 3 repos, pool 2:

scan 1: 3 task(s) in scope, 3 new/updated
        dispatch 803 ... dispatch 801        <- two turns interleaved
        REPORT 801 / REPORT 803              <- both land while scan holds
        pool: 2 dispatched, 1 pending (max 2 turn(s) at once; pending reconsidered next scan)
scan 2: 1 new/updated -> dispatch 802 -> noted -> Done   <- backlog drained
peak concurrent LLM requests: 2 (pool bound 2)
status.json: {"active_turns":0,"pending":0,"max_concurrent_turns":2,
              "dispatched":1,"reports":1,"running":false,...}

Two-loop race test output evidence (from the suite): both verticals claim, exactly one turn runs, loser logs claim lost #700 ... skipping to next candidate, winner completes In Progress -> Done.

Config knobs (fleet rollout)

[loop]
max_concurrent_turns = 2          # or 3
[loop.repo_map]
code  = "/srv/work/harness"
study = "/srv/work/notes"

[redmine]
claim_enabled = true
claim_status = "In Progress"      # default
claimable_status = "New"          # default

Fleet loops: set both blocks; keep [redmine.status_map] "In Progress" = "Resolved" (it now keys off the claim status). Single-loop hosts need no config change (both features default off/1).