rework(afk): one priority-ordered spawn pass — drain the pipeline before filling it #186

Merged
dominik.polakovics merged 1 commit from afk/185 into main 2026-07-20 16:19:35 +02:00

One spawn loop for the whole AFK/autoland fleet, ordered by pipeline stage, replacing the two independent loops that raced for the same instance cap — plus the decided-along-the-way counter separation. Note: PR #184 merged while this run was starting; this branch is based on top of it.

What ships

The single spawn pass (internal/afk/spawn.go): SpawnOnce, serialized under its own spawnMu — one repo listing, one liveness snapshot, one shared forced-auth memo per pass. The old sweeps became gather-only producers: landerCandidates (autoland.go) and newWorkCandidates (scheduler.go) emit candidates and never launch. spawnPass stable-sorts by SpawnStage (StageLander < StageFix < StageNewWork — priority is DATA on the candidate) and launches down the list: spawned counts the slot, atCap raises the snapshot to that repo's cap (the scheduler's per-repo floor-raise, now also replacing autoland's whole-sweep stop, which was correct only under a single global cap). Both loops and the HTTP toggle-on/reset kicks invoke the one pass; the reaper tick runs it right after ReapOnce — drain-then-fill made literal.

Cap ownership: UnderCap left both pure predicates (ShouldLaunchAuto, ShouldSpawnLander) — the pass is the cap's sole consumer, the locked per-launch guards in launch/LaunchLander stay as the authoritative backstop on the same path. Producers keep a cheap at-cap veto against the pass snapshot before their forge reads (load-shedding hint, never enforcement) — for the new-work producer that veto is new and saves ReadyIssues+FilterClaimable on at-cap repos.

#182's extension point: StageFix is reserved between lander and new work; the fix-forward producer plugs in by emitting candidates at that rank — an addition to the gather, not a modification of the pass. Exercised today by stub candidates in the pass-core tests.

Counter separation: reapRun's failure switch is guarded to afk_manual/afk_auto. A lander death/timeout no longer increments consecutive_failures (lander flakiness must not pause unrelated AFK work), and a lander reject-success no longer resets it (a correct rejection is evidence FOR the brake, not against it — under fix-forward the old behavior would compound). Landers get no counter; the budget clock stays deliberately shared.

Docs: ADR-0049 pins the pass, the no-queue stance (candidates re-derived every pass — ADR-0015 poll-only stands), the counter decision, and the rejected alternatives; CONTEXT.md gains the Spawn pass domain term and the counter-scoping edits. ADR-0048 verified untouched-correct (it pins the contract the engine reads, not the engine).

Acceptance criteria → proof

  • Exactly one code path consumes EffectiveCap for launch decisions — spawnPass (+ its in-pass producer hints and the locked backstops); zero cap reads left in the predicates. Pinned structurally by TestShouldLaunchAuto/TestShouldSpawnLander (no cap rows) and behaviorally by the pass tests.
  • Spawn order lander > fix > new AFK pinned over a mixed candidate set — TestSpawnStageOrder, TestSpawnPass_stageOrderIsTheOnlySortKey (includes the StageFix stub).
  • At cap, a pending lander beats a pending new-work candidate — TestSpawnOnce_lastSlotGoesToLanderNotNewWork (also proves the loser is deferred, not lost, and left no claim-branch side effects).
  • Cap cannot be over-subscribed by concurrent scheduler/reaper ticks — TestSpawnOnce_concurrentReaperAndSchedulerTicksCannotOversubscribeCap: the literal tick bodies race against one repo at cap 1 with both candidate kinds spawnable; green under -race -count=4.
  • Lander death/timeout never increments, lander reject/merge-success never resets, AFK accounting unchanged — TestReap_landerDeathDoesNotStrike (seeds 1 and 0), TestReap_landerRejectDoesNotResetCounter, TestReap_landerMergeDoesNotResetCounter, reworked TestAutolandCycleIntegration/lander_death_does_not_strike_and_a_paused_repo_stops_spawning (pause now seeded via the AFK path it belongs to); TestReap_death/TestReap_timeout/TestReap_successCycle/TestThreeStrikes_* untouched and green.
  • Behavior-identical for autoland-off repos — every existing scheduler/three-strikes/claim test passes with mechanical call rewires only (ScheduleOnceSpawnOnce); the only assertion-level test changes are the two dropped UnderCap predicate rows (coverage moved to the pass tests) and the deliberately flipped lander-strike pins.
  • Extension point exercised at the fix rank — stub candidates at StageFix flow through the pass in spawn_test.go.

Decisions worth review

  • Both producers now run on both cadences (and on kicks): landers are considered on the scheduler tick, new work on the reaper tick. Intended — the pass must see all stages to enforce priority — and the producers' pre-read at-cap vetoes keep an at-cap fleet from paying forge reads it cannot spend. Documented as an ADR-0049 consequence.
  • A paused repo still spawns no landers (the Paused predicate term stays): pause suppression is unchanged; only the counter's feeding direction changed.
  • Landers get no counter at all rather than a separate one: nothing would consume it today (ADR-0049 records the alternative for when something does).

Verification

gofmt clean · go build ./... · full go test ./... green (real-tmux afk cycle suites included) · golangci-lint run (2.12.2, CI's pin): 0 issues · concurrency AC additionally under -race -count=4 · web/ untouched.

Closes #185

One spawn loop for the whole AFK/autoland fleet, ordered by pipeline stage, replacing the two independent loops that raced for the same instance cap — plus the decided-along-the-way counter separation. Note: PR #184 merged while this run was starting; this branch is based on top of it. ## What ships **The single spawn pass** (`internal/afk/spawn.go`): `SpawnOnce`, serialized under its own `spawnMu` — one repo listing, one liveness snapshot, one shared forced-auth memo per pass. The old sweeps became gather-only producers: `landerCandidates` (autoland.go) and `newWorkCandidates` (scheduler.go) emit candidates and never launch. `spawnPass` stable-sorts by `SpawnStage` (`StageLander < StageFix < StageNewWork` — priority is DATA on the candidate) and launches down the list: spawned counts the slot, atCap raises the snapshot to that repo's cap (the scheduler's per-repo floor-raise, now also replacing autoland's whole-sweep stop, which was correct only under a single global cap). Both loops and the HTTP toggle-on/reset kicks invoke the one pass; the reaper tick runs it right after `ReapOnce` — drain-then-fill made literal. **Cap ownership**: `UnderCap` left both pure predicates (`ShouldLaunchAuto`, `ShouldSpawnLander`) — the pass is the cap's sole consumer, the locked per-launch guards in `launch`/`LaunchLander` stay as the authoritative backstop on the same path. Producers keep a cheap at-cap veto against the pass snapshot before their forge reads (load-shedding hint, never enforcement) — for the new-work producer that veto is new and saves `ReadyIssues`+`FilterClaimable` on at-cap repos. **#182's extension point**: `StageFix` is reserved between lander and new work; the fix-forward producer plugs in by emitting candidates at that rank — an addition to the gather, not a modification of the pass. Exercised today by stub candidates in the pass-core tests. **Counter separation**: `reapRun`'s failure switch is guarded to `afk_manual`/`afk_auto`. A lander death/timeout no longer increments `consecutive_failures` (lander flakiness must not pause unrelated AFK work), and a lander reject-success no longer resets it (a correct rejection is evidence FOR the brake, not against it — under fix-forward the old behavior would compound). Landers get no counter; the budget clock stays deliberately shared. **Docs**: ADR-0049 pins the pass, the no-queue stance (candidates re-derived every pass — ADR-0015 poll-only stands), the counter decision, and the rejected alternatives; CONTEXT.md gains the **Spawn pass** domain term and the counter-scoping edits. ADR-0048 verified untouched-correct (it pins the contract the engine reads, not the engine). ## Acceptance criteria → proof - Exactly one code path consumes `EffectiveCap` for launch decisions — `spawnPass` (+ its in-pass producer hints and the locked backstops); zero cap reads left in the predicates. Pinned structurally by `TestShouldLaunchAuto`/`TestShouldSpawnLander` (no cap rows) and behaviorally by the pass tests. - Spawn order `lander > fix > new AFK` pinned over a mixed candidate set — `TestSpawnStageOrder`, `TestSpawnPass_stageOrderIsTheOnlySortKey` (includes the StageFix stub). - At cap, a pending lander beats a pending new-work candidate — `TestSpawnOnce_lastSlotGoesToLanderNotNewWork` (also proves the loser is deferred, not lost, and left no claim-branch side effects). - Cap cannot be over-subscribed by concurrent scheduler/reaper ticks — `TestSpawnOnce_concurrentReaperAndSchedulerTicksCannotOversubscribeCap`: the literal tick bodies race against one repo at cap 1 with both candidate kinds spawnable; green under `-race -count=4`. - Lander death/timeout never increments, lander reject/merge-success never resets, AFK accounting unchanged — `TestReap_landerDeathDoesNotStrike` (seeds 1 and 0), `TestReap_landerRejectDoesNotResetCounter`, `TestReap_landerMergeDoesNotResetCounter`, reworked `TestAutolandCycleIntegration/lander_death_does_not_strike_and_a_paused_repo_stops_spawning` (pause now seeded via the AFK path it belongs to); `TestReap_death`/`TestReap_timeout`/`TestReap_successCycle`/`TestThreeStrikes_*` untouched and green. - Behavior-identical for autoland-off repos — every existing scheduler/three-strikes/claim test passes with mechanical call rewires only (`ScheduleOnce`→`SpawnOnce`); the only assertion-level test changes are the two dropped UnderCap predicate rows (coverage moved to the pass tests) and the deliberately flipped lander-strike pins. - Extension point exercised at the fix rank — stub candidates at `StageFix` flow through the pass in `spawn_test.go`. ## Decisions worth review - **Both producers now run on both cadences** (and on kicks): landers are considered on the scheduler tick, new work on the reaper tick. Intended — the pass must see all stages to enforce priority — and the producers' pre-read at-cap vetoes keep an at-cap fleet from paying forge reads it cannot spend. Documented as an ADR-0049 consequence. - **A paused repo still spawns no landers** (the `Paused` predicate term stays): pause suppression is unchanged; only the counter's feeding direction changed. - **Landers get no counter at all** rather than a separate one: nothing would consume it today (ADR-0049 records the alternative for when something does). ## Verification `gofmt` clean · `go build ./...` · full `go test ./...` green (real-tmux afk cycle suites included) · `golangci-lint run` (2.12.2, CI's pin): 0 issues · concurrency AC additionally under `-race -count=4` · `web/` untouched. Closes #185
rework(afk): one priority-ordered spawn pass — drain before fill, landers off the strike counter
All checks were successful
ci / native (pull_request) Successful in 6m27s
07b3cd77d3
SpawnOnce (internal/afk/spawn.go) is now the fleet's single spawn decision
point, serialized under spawnMu: the old ScheduleOnce and AutolandOnce sweeps
became gather-only candidate producers (newWorkCandidates, landerCandidates)
that never launch, and spawnPass stable-sorts their candidates by SpawnStage
— StageLander > StageFix > StageNewWork, priority as data — and spends the
live-instance cap down the list with the per-repo atCap floor-raise. Both
loops and the HTTP toggle-on/reset kicks invoke the one pass; StageFix is
issue #182's reserved rank, exercised today by stub candidates in the pass
tests.

UnderCap leaves both spawn predicates (ShouldLaunchAuto, ShouldSpawnLander):
the pass is the cap's sole consumer, with the locked per-launch guards in
launch/LaunchLander as the authoritative backstop. Autoland's whole-sweep
stop on ErrOverCap became the per-repo floor-raise — correct under per-repo
cap overrides.

reapRun's failure-accounting switch is guarded to the two AFK kinds: a
lander outcome never moves consecutive_failures in either direction — a
flaky lander must not pause a repo's unrelated AFK work, and a lander
correctly rejecting a broken PR must not clear the strikes that broken work
earned. Landers get no counter; the budget clock stays shared.

ADR-0049 pins the decisions; CONTEXT.md gains the spawn-pass domain term.
AC pins: stage-order table tests, at-cap lander-over-new-work preference,
concurrent scheduler/reaper ticks never over-subscribing the cap (-race),
and the counter separation in both directions.

Closes #185

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/coding-lab!186
No description provided.