Skip to content

fix(gateway): owner-only engine bot decisions, and ENGINE_BOT in Helm - #65

Merged
sayed710 merged 11 commits into
mainfrom
claude/helm-engine-bot-parity
Sep 26, 2026
Merged

sayed710 merged 11 commits into
mainfrom
claude/helm-engine-bot-parity

Conversation

@sayed710

@sayed710 sayed710 commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

Problem

The Helm chart never rendered ENGINE_BOT, so a Helm release offered Play vs Computer with an opponent that never moved (audit Ops row). Compose enables it, and the gateway image already ships the pinned Stockfish and STOCKFISH_PATH.

Turning it on per replica was not safe on main, and the chart defaults to gateway.replicas: 2. Every replica with a session in a bot game ran an EngineBotMover that chose moves from its own cached GameAuthority. A non-owner's copy never sees the owner's moves, which reach it as room broadcasts that never touch the authority. A reconnect to the other pod, a second tab, or a spectator was enough to make that replica ask the engine about an old position on every broadcast and route the answer to the owner. The owner applied it whenever it was legal. Reproduced with two nodes: the owner applied …Nf6 at ply 4, computed by the non-owner for ply 1.

Change

Gateway: only the owner decides

  • RedisCommandRouter.prepareOwnership(gameId) is the router's existing ownership step (valid lease, or claim, then the existing takeover rehydrateIfStale) with no command applied. holdsOwnership(gameId) is a synchronous check: valid lease and no reload debt. No lease logic is reimplemented.
  • EngineBotMover calls prepareOwnership before provider.play(). A non-owner returns without an engine call. An unowned game (bot as White at ply 0, or an owner that has gone away) is claimed and rehydrated from the event log before its position is read.
  • After the engine returns, the move is routed only if holdsOwnership is still true and the local state still has the bot to move at the same ply and FEN. Otherwise the result is dropped, and the re-run queued by the broadcast that changed the game computes from the current position. The check and route() have no I/O between them. While the lease is valid no other node can own the game, and in the checked position only the bot has a legal move. No wire-protocol change.
  • An ended broadcast unregisters the game on every replica. A non-owner's copy never reaches over.
  • Single node (LocalCommandRouter): no ownership gate, and the post-engine position check still applies.

Helm

  • gateway.engineBot.enabled (default true) renders ENGINE_BOT="1" on the gateway Deployment only; false removes it.
  • Stockfish and STOCKFISH_PATH come only from the gateway image. The chart adds no engine configuration.
  • Rendered diff against main: rolling, blue/green and canary differ only by that one variable. With the bot disabled, the render is byte-identical to main.

Review-round fixes (Qodo, Greptile, independent Gemini review)

  • Reload debt belongs to a claim. rehydrateIfStale (shared with route()) tracked in-flight reloads by game only: a slow reload begun for an earlier claim could land after a later claim and mark a copy that predates another owner's moves as fresh. Claims now get a process-wide, never-reused number; only a reload for the current claim settles the debt. prepareOwnership re-checks the lease after reloading.
  • A SET NX re-claim is a new claim. When a renewal threw, OwnershipRegistry kept the game in its owned set, so a later SET NX after the lease had lapsed (and another node had advanced the game) fired no onClaimed and nothing reloaded. A SET NX that creates the key is now always a new claim. This predates the PR and affected route() too.
  • Lease lapse mid-think no longer strands the turn: a dropped result is followed by one more pass.
  • Recovery without events: a non-owner re-checks every lease TTL, reloading its copy from the event log, so a game whose owner died is claimed and continued and a game whose ended broadcast was lost is unregistered. A copy that already shows the game over unregisters before any ownership traffic.
  • Registrations are bounded by local sessions (new optional RealtimeGateway hook onGameUnloaded). When a game's last session on a node leaves, the mover marks it and takes a pass, and drops it only when a pass fails to own it (a join clears the mark). So a non-owner lets go, a claim still in flight that succeeds keeps the game (no ownerless-mover stall), a former owner lets go on its first failed claim, and the owner keeps the game for a player who reconnected elsewhere. Re-checks and their replays run only while someone on that replica is in the game. (Qodo/Greptile performance finding on fc810f4; race found on 1354e38 by Qodo, Greptile and the delegated reviews.)
  • Helm refuses a non-boolean gateway.engineBot.enabled (a quoted "false" is truthy and would leave the bot on).

Docs: ADR-0080 amendment (design, costs, remaining limits), ADR-0010 cross-reference, docs/RUNNING.md, chart README (engine footprint), PROJECT_STATE Increment 69 (Increment 68 belongs to #64 and is untouched).

Proof

  • services/gateway/test/engine-bot-multinode.integration.test.ts runs on real Redis (CI gateway-service job, zero-skip). It uses the production RedisCommandRouter, OwnershipRegistry and per-node createRedisPubSub, and covers:
    • two replicas observe one game and only the owner calls provider.play(), including on duplicate broadcasts; the non-owner holds a stale bot-to-move copy and never computes from it;
    • bot as White at ply 0: exactly one replica claims and moves;
    • takeover: the engine is asked about the rehydrated position, not the stale copy;
    • ownership lost mid-think: the stale answer, legal at the current ply, is not applied;
    • ownership lost and regained mid-think: the untouched cached copy matches the old FEN, and the reload-debt clause rejects it;
    • a finished game makes both replicas' movers let go.
  • engine-bot.test.ts covers the same gate and post-engine checks with a scripted gate, including the game advancing or ending mid-think.
  • Mutation check: removing the pre-engine gate fails 7 tests; the post-engine check, 4; the ended unregister, 3; the reload-debt clause, 1.
  • scripts/helm-snapshot-test.sh pins:
    • ENGINE_BOT on by default, off when disabled, and still on under blue/green and canary;
    • absent from the search-indexer Deployment, and no STOCKFISH_* env anywhere;
    • the full gateway env list, so nothing else can change unnoticed;
    • the tournament reporter's variables unchanged.
  • Setting the default to false fails 6 of these checks.
  • Local results: gateway npm test 34/34 with no skips on Redis 7; helm lint passes; kubeconform passes for the default, disabled, external-datastore, external-secrets, blue/green and canary renders; snapshot test 116/116.

Not in scope

BOT_AUTO_ANALYZE, ANTICHEAT_AUTO_ANALYZE, the tournament reporter, the search indexer, the games projection, ratings, timed games and auth are untouched. There is no migration.

Merged latest main (#64) normally; this PR is Increment 69.

Mutation evidence (final code, 84bba34): fifteen deliberate defects were each introduced alone, against the compiled test build, one process at a time, and each failed named tests: pre-engine gate, post-engine check, ended unregister, reload-debt clause, claim-generation check, re-run after drop, non-owner re-check, reload before re-check, finished-copy unregister, post-reload finished unregister, SET NX new-claim rule, gateway unload hook, and the session-leave rule three ways (never releasing, releasing at once regardless of the claim, a join not clearing the mark). The multi-node suite fails in seconds by name when Redis is unreachable (test-only preflight; production retry settings unchanged).

…e ENGINE_BOT in Helm

Every gateway replica with a session in a bot game ran an EngineBotMover that chose
moves from its own cached GameAuthority. A non-owner's copy never sees the owner's
moves, so it asked the engine about an old position on every broadcast and routed the
answer to the owner, which applied it whenever it was legal (reproduced: ...Nf6 applied
at ply 4, computed for ply 1). That made per-replica ENGINE_BOT unsafe under the chart's
default two gateway replicas.

- RedisCommandRouter.prepareOwnership: the router's ownership step (valid lease or
  claim, then the takeover reload) without applying a command. holdsOwnership: a
  synchronous valid-lease-and-no-reload-debt check.
- EngineBotMover asks the engine only after prepareOwnership succeeds, and submits
  only if it still owns the game and the bot is still to move at the same ply and
  FEN. An `ended` broadcast unregisters the game on every replica.
- Helm: gateway.engineBot.enabled (default true) renders ENGINE_BOT="1" on the
  gateway. Stockfish still comes only from the gateway image.
- Tests: scripted-gate unit tests; real-Redis two-node tests with the production
  router and registry; Helm snapshot pins enabled, disabled, per strategy, and the
  rest of the gateway env contract.
…b/sub

The two simulated replicas shared one in-memory bus, so broadcasts crossed between
them synchronously. Each node now uses the production createRedisPubSub, as serve.ts
wires it, so the other node hears a broadcast asynchronously through Redis. Negative
checks watch a named 500 ms window, the terminal test waits for both movers to let
go, and the non-owner test asserts the exact number of ownership checks.
@coderabbitai

coderabbitai Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: a4f548d6-6122-4ebd-ac32-85232236aa89


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Make engine bot decisions owner-only and enable Helm parity

🐞 Bug fix ⚙️ Configuration changes 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Restricts engine move computation and submission to the current authoritative gateway owner.
• Enables ENGINE_BOT by default in Helm with an explicit disable switch.
• Adds unit, Redis multinode, Helm snapshot, and architecture documentation coverage.
Diagram

sequenceDiagram
    participant PS as Game Pub/Sub
    participant M as Bot Mover
    participant R as Redis Router
    participant O as Ownership Registry
    participant A as Game Authority
    participant E as Chess Engine
    PS->>M: State broadcast
    M->>R: Prepare ownership
    R->>O: Validate or claim lease
    O-->>R: Ownership result
    alt Another replica owns game
        R-->>M: Skip computation
    else Local replica owns game
        R->>A: Rehydrate takeover state
        R-->>M: Authoritative state ready
        M->>E: Compute current position
        E-->>M: Candidate move
        M->>R: Recheck ownership
        M->>A: Verify ply and FEN
        alt Ownership or position changed
            M-->>M: Drop stale result
        else Result remains current
            M->>R: Route bot move
            R->>A: Apply locally
        end
    end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Add expected position to commands
  • ➕ Rejects stale bot moves at the authority boundary.
  • ➕ Provides reusable optimistic concurrency for other command producers.
  • ➖ Requires a wire-protocol and command-consumer change.
  • ➖ Does not prevent non-owners from wasting engine capacity.
  • ➖ Still requires fresh state before computing a useful move.
2. Run a singleton bot worker
  • ➕ Centralizes engine computation in one process.
  • ➕ Eliminates duplicate per-replica engine movers.
  • ➖ Introduces another deployment, routing path, and failover mechanism.
  • ➖ Creates a shared bottleneck for all bot games.
  • ➖ Duplicates responsibilities already handled by game ownership leases.

Recommendation: Keep the PR's ownership-gated mover. It reuses the established lease and takeover-rehydration model, prevents wasted non-owner computation, and adds post-engine race protection without changing the command protocol or introducing another service.

Files changed (12) +666 / -5

Bug fix (3) +82 / -2
command-forwarder.tsExpose ownership preparation and validation for bot moves +24/-0

Expose ownership preparation and validation for bot moves

• Adds 'prepareOwnership' to claim or confirm a lease and complete takeover rehydration before computation. Adds synchronous 'holdsOwnership' validation that rejects expired leases and unresolved reload debt.

services/gateway/src/command-forwarder.ts

engine-bot.tsGate bot computation and submission by authoritative ownership +53/-1

Gate bot computation and submission by authoritative ownership

• Introduces an optional ownership gate, skips engine calls on non-owners, and drops results when ownership, turn, ply, FEN, or game status changed while thinking. Terminal broadcasts now unregister stale non-owner subscriptions.

services/gateway/src/engine-bot.ts

serve.tsWire Redis ownership into the engine bot mover +5/-1

Wire Redis ownership into the engine bot mover

• Retains the concrete Redis router as the bot ownership implementation and passes it to 'EngineBotMover'. Single-node routing remains unchanged because no ownership gate is supplied.

services/gateway/src/serve.ts

Tests (3) +526 / -0
helm-snapshot-test.shPin the Helm engine bot environment contract +37/-0

Pin the Helm engine bot environment contract

• Verifies default and disabled rendering, gateway-only placement, progressive delivery strategies, absence of chart-provided Stockfish configuration, and compatibility with the tournament reporter.

scripts/helm-snapshot-test.sh

engine-bot-multinode.integration.test.tsProve engine bot safety across Redis-backed replicas +327/-0

Prove engine bot safety across Redis-backed replicas

• Adds real-Redis two-node tests covering owner-only computation, duplicate broadcasts, initial claims, takeover rehydration, mid-think lease changes, stale-result rejection, and terminal cleanup.

services/gateway/test/engine-bot-multinode.integration.test.ts

engine-bot.test.tsTest ownership and stale-position bot safeguards +162/-0

Test ownership and stale-position bot safeguards

• Adds scripted ownership and held-engine tests proving non-owners never compute, stale results are dropped, current positions are recomputed, and terminal broadcasts stop bot work.

services/gateway/test/engine-bot.test.ts

Documentation (4) +43 / -3
README.mdDocument the Helm engine bot switch +12/-0

Document the Helm engine bot switch

• Explains that Helm enables the engine bot by default, relies on the gateway image for Stockfish, and can disable the mover through 'gateway.engineBot.enabled'.

deploy/helm/gambit/README.md

PROJECT_STATE.mdRecord owner-only bot decisions as Increment 68 +12/-1

Record owner-only bot decisions as Increment 68

• Updates project state with the stale-replica failure mode, ownership-gated solution, Helm parity, test coverage, and unchanged scope boundaries.

docs/PROJECT_STATE.md

RUNNING.mdDescribe ENGINE_BOT defaults across Compose and Helm +2/-2

Describe ENGINE_BOT defaults across Compose and Helm

• Updates operator guidance to show that both Compose and Helm enable the mover and documents the Helm disable setting.

docs/RUNNING.md

0080-engine-bot-opponent.mdAmend engine bot architecture for multinode ownership +17/-0

Amend engine bot architecture for multinode ownership

• Records why move decisions must occur on the owner, how takeover rehydration and post-engine checks work, and the remaining lease-expiry retry limitation. It also documents Helm enablement and Redis integration coverage.

docs/adr/0080-engine-bot-opponent.md

Other (2) +15 / -0
gateway.yamlRender ENGINE_BOT for enabled gateway deployments +7/-0

Render ENGINE_BOT for enabled gateway deployments

• Conditionally adds 'ENGINE_BOT="1"' to the gateway container only. The chart deliberately leaves Stockfish and 'STOCKFISH_PATH' to the gateway image.

deploy/helm/gambit/templates/gateway.yaml

values.yamlEnable the Helm engine bot by default +8/-0

Enable the Helm engine bot by default

• Adds 'gateway.engineBot.enabled' with a default of 'true' and documents its multinode safety and image dependency.

deploy/helm/gambit/values.yaml

@qodo-code-review

qodo-code-review Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Disconnects can strand bot turns ✓ Resolved 🐞 Bug ☼ Reliability ⭐ New
Description
localSessionsGone() unregisters whenever claimedHere() is false, but it neither waits for nor
cancels an asynchronous prepareOwnership() already started by registerGame(). If the last
session leaves while that claim is pending, the operation can subsequently claim and retain the game
while its subscription is gone, so later moves routed through this owner cannot wake the bot mover.
Code

services/gateway/src/engine-bot.ts[R288-289]

+  localSessionsGone(gameId: string): void {
+    if (this.ownership && !this.ownership.claimedHere(gameId)) this.unregisterGame(gameId);
Relevance

●●● Strong

In-flight ownership preparation survives unregister, potentially losing the subscription needed to
wake later bot turns.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
registerGame() immediately launches attemptMove(), whose doMove() awaits asynchronous
ownership preparation; unregisterGame() removes only the subscription and timer, not that
in-flight drain. The new gateway callback invokes localSessionsGone() synchronously when the room
empties, and claimedHere() only observes ownership already recorded in the registry, so a claim
currently awaiting Redis or rehydration appears unowned at cleanup but can complete afterward.

services/gateway/src/engine-bot.ts[108-136]
services/gateway/src/engine-bot.ts[142-185]
services/gateway/src/command-forwarder.ts[290-301]
packages/realtime-gateway/src/gateway.ts[302-313]
services/gateway/src/serve.ts[524-539]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The last-session cleanup can unregister a game while an ownership attempt is still pending. That attempt can subsequently claim and retain the game without restoring its mover subscription, leaving later bot turns stranded.

## Fix Focus Areas
- services/gateway/src/engine-bot.ts[280-291]
- packages/realtime-gateway/src/gateway.ts[309-312]
- services/gateway/test/engine-bot.test.ts[546-566]

## Recommended Fix
Track that local sessions are absent and defer the unregister decision until the game's active mover drain has settled. If the pending attempt becomes owner, preserve or restore the subscription; if it remains a non-owner, unregister it, and add a test that unloads the game while `prepareOwnership()` is blocked before allowing the claim to complete.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Spectators create permanent replay load ✓ Resolved 🐞 Bug ➹ Performance
Description
scheduleRecheck reloads and fully replays a registered game from the durable log every lease
interval whenever this replica is not the owner. Because registration is triggered by a join but
survives session departure until the game ends, visitors to abandoned or unlimited bot games leave
permanent database and CPU work that grows with both game count and history length.
Code

services/gateway/src/engine-bot.ts[R292-295]

+    const timer = setTimeout(() => {
+      this.rechecks.delete(gameId);
+      this.reloadBeforeNextPass.add(gameId);
+      void this.attemptMove(gameId);
Relevance

●●● Strong

The PR explicitly acknowledges this lifetime replay cost as a remaining limit requiring follow-up.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
A completed join invokes the gateway's game-loaded callback, which registers the bot mover,
including for anonymous spectators. The new timer marks every non-owner pass for reloadFromLog,
while the mover only unregisters on terminal or non-bot state; reloadFromLog reads the complete
event log and rebuilds the aggregate, and the amendment itself acknowledges that one spectator visit
retains this polling for the game's lifetime with replay cost growing quadratically.

packages/realtime-gateway/src/gateway.ts[157-166]
packages/realtime-gateway/src/gateway.ts[208-215]
services/gateway/src/serve.ts[529-536]
services/gateway/src/engine-bot.ts[178-187]
services/gateway/src/engine-bot.ts[282-299]
packages/realtime-gateway/src/authority.ts[216-239]
docs/adr/0080-engine-bot-opponent.md[78-80]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The non-owner recovery timer performs a full durable-log replay every lease interval for every bot game ever registered on the replica. Registrations are not removed when local interest disappears, allowing abandoned games and transient spectator joins to accumulate persistent database and CPU load.

## Fix Focus Areas
- services/gateway/src/engine-bot.ts[282-299]
- packages/realtime-gateway/src/gateway.ts[208-215]
- services/gateway/src/serve.ts[529-536]

## Recommended Fix
Tie non-owner recovery registration to active local game interest and unregister when the last local session leaves, while retaining ownership recovery for games that still have local participants. Avoid full-history reloads merely to test whether another owner remains alive; use a lightweight durable terminal-state or incremental-event check, and perform the full reload only after this replica successfully claims ownership.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. Reclaims can trust stale positions ✓ Resolved 🐞 Bug ≡ Correctness
Description
prepareOwnership() awaits a game-keyed rehydration and returns true without tying that reload to
the claim generation or rechecking the lease afterward. When ownership is lost and reclaimed while
the old reload is pending, its completion can erase the new claim's reload debt, causing the bot to
compute against an obsolete snapshot and fail its durable append without another trigger to retry
the turn.
Code

services/gateway/src/command-forwarder.ts[R277-278]

+    await this.rehydrateIfStale(gameId);
+    return true;
Relevance

●●● Strong

Concrete generation race can clear reload debt and trust stale state; correctness findings exposing
unsafe ownership transitions are likely accepted.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The router represents freshness with a plain game-ID set and unconditionally deletes that ID when
any shared reload completes. Release does not invalidate router rehydration state, while a later
claim invokes the same hook and reuses the same ID; PostgreSQL loading is asynchronous and may
return the snapshot established before intervening events, after which the engine's ownership check
compares against that same stale local state.

services/gateway/src/command-forwarder.ts[205-280]
services/gateway/src/command-forwarder.ts[273-287]
services/gateway/src/ownership.ts[228-284]
packages/persistence/src/pg/event-store.ts[155-161]
packages/realtime-gateway/src/authority.ts[404-422]
services/gateway/src/engine-bot.ts[210-247]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
An in-flight reload from an earlier ownership claim can clear the freshness debt belonging to a later claim for the same game. The preparation path can consequently return a stale authority snapshot as ready.

## Fix Focus Areas
- services/gateway/src/command-forwarder.ts[205-287]
- services/gateway/src/ownership.ts[228-284]

## Recommended Fix
Associate reload debt and in-flight rehydration with an ownership generation or fencing token rather than only the game ID. Clear debt only when the completed reload still belongs to the current generation, invalidate pending generations on release, and recheck valid ownership after rehydration before returning true.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. Finished games retain bot subscriptions ✓ Resolved 🐞 Bug ☼ Reliability
Description
EngineBotMover.registerGame removes a non-owner's subscription only when its handler receives an
ended broadcast, even though that replica's cached authority never becomes terminal. If Redis
drops the fire-and-forget terminal publish or the terminal event is sent before the asynchronous
subscription is active, no later event is guaranteed to unregister the completed game, so each such
game remains retained for the process lifetime.
Code

services/gateway/src/engine-bot.ts[R103-105]

+        if (msg.t === 'ended') {
+          this.unregisterGame(gameId);
+          return;
Relevance

●● Moderate

Message-loss cleanup concern is plausible, but the change explicitly relies on terminal broadcasts
and adds integration coverage.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The new branch makes receipt of ended the only terminal cleanup signal for a stale non-owner
replica, while the pub/sub implementation deliberately neither retries failed publishes nor waits
for subscription activation. Helm now activates this mover on every gateway replica by default,
making the previously dormant multi-replica lifecycle path active in standard chart deployments.

services/gateway/src/engine-bot.ts[100-106]
packages/realtime-gateway/src/pubsub.ts[181-188]
packages/realtime-gateway/src/pubsub.ts[206-217]
packages/realtime-gateway/src/authority.ts[414-418]
deploy/helm/gambit/values.yaml[139-146]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A non-owner's engine mover depends solely on an `ended` pub/sub message to unsubscribe, but Redis pub/sub is explicitly best-effort and subscription activation is asynchronous. Add a cleanup path that does not require receipt of that individual terminal broadcast, such as reconciling registered bot games with durable game state or explicitly unregistering when the gateway drops its final local room; retain the broadcast handler as the fast path.

## Fix Focus Areas
- services/gateway/src/engine-bot.ts[100-106]
- packages/realtime-gateway/src/pubsub.ts[181-217]
- packages/realtime-gateway/src/gateway.ts[315-326]

## Recommended Fix
Introduce a reliable lifecycle/reconciliation mechanism for mover registrations so a missed `ended` message cannot leave a game subscribed indefinitely. Wire it to remove the mover when the local gateway no longer serves the game and/or periodically or on a durable lifecycle callback verify terminal state; add a Redis-pub/sub failure or subscribe-race test that proves completed games are eventually unregistered.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

5. Pod loss strands bot games ✓ Resolved 🐞 Bug ☼ Reliability
Description
EngineBotMover.doMove returns when prepareOwnership() finds the existing lease elsewhere and
schedules no retry for when that lease expires. If the owning pod dies while the bot is thinking and
a client reconnects before lease expiry, the one registration attempt returns and no subsequent
broadcast exists to wake any replica, leaving that game at the bot's turn indefinitely.
Code

services/gateway/src/engine-bot.ts[163]

+      if (this.ownership && !(await this.ownership.prepareOwnership(gameId))) return;
Relevance

● Weak

The PR explicitly documents this unchanged lease-expiry limitation and defers timer-driven retry as
follow-up.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Registration performs one immediate attempt and otherwise relies entirely on game broadcasts; the
new false return does not enqueue another attempt. The ownership registry only renews leases already
held locally, while command broadcasts are emitted only after an applied command, so an owner crash
produces no event that can wake the waiting mover after the Redis lease expires.

services/gateway/src/engine-bot.ts[98-115]
services/gateway/src/engine-bot.ts[124-179]
services/gateway/src/ownership.ts[302-350]
packages/realtime-gateway/src/authority.ts[388-429]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Bot games can remain permanently stuck when the owning gateway dies and another replica checks ownership before the abandoned lease expires. The failed ownership preparation has no timer, ownership callback, or other trigger that retries after expiration.

## Fix Focus Areas
- services/gateway/src/engine-bot.ts[124-179]
- services/gateway/src/ownership.ts[254-284]

## Recommended Fix
Schedule a bounded, cancellable retry whenever ownership preparation returns false, using lease-aware delay or backoff to avoid busy polling. Keep at most one retry per subscribed game, cancel it on unregister or shutdown, and rerun through the existing serialized `attemptMove` path so only the successful claimant invokes the engine.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
✅ Web pages:
  +2 more
Review mode: 🚀 Fast: This is a localized Redis connection-options refactor with focused tests; it has limited blast radius and no security, API, schema, or concurrency-logic change.

Grey Divider

Tip of the day
💡 Did you know, you can type 'qodo, fix this' on a finding and the fix lands right on your PR

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Previous reviews

Review updated until commit a38b9ce

Results up to commit 373c822 🧠 Deep


🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)


Remediation recommended
1. Reclaims can trust stale positions ✓ Resolved 🐞 Bug ≡ Correctness
Description
prepareOwnership() awaits a game-keyed rehydration and returns true without tying that reload to
the claim generation or rechecking the lease afterward. When ownership is lost and reclaimed while
the old reload is pending, its completion can erase the new claim's reload debt, causing the bot to
compute against an obsolete snapshot and fail its durable append without another trigger to retry
the turn.
Code

services/gateway/src/command-forwarder.ts[R277-278]

+    await this.rehydrateIfStale(gameId);
+    return true;
Relevance

●●● Strong

Concrete generation race can clear reload debt and trust stale state; correctness findings exposing
unsafe ownership transitions are likely accepted.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The router represents freshness with a plain game-ID set and unconditionally deletes that ID when
any shared reload completes. Release does not invalidate router rehydration state, while a later
claim invokes the same hook and reuses the same ID; PostgreSQL loading is asynchronous and may
return the snapshot established before intervening events, after which the engine's ownership check
compares against that same stale local state.

services/gateway/src/command-forwarder.ts[205-280]
services/gateway/src/command-forwarder.ts[273-287]
services/gateway/src/ownership.ts[228-284]
packages/persistence/src/pg/event-store.ts[155-161]
packages/realtime-gateway/src/authority.ts[404-422]
services/gateway/src/engine-bot.ts[210-247]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
An in-flight reload from an earlier ownership claim can clear the freshness debt belonging to a later claim for the same game. The preparation path can consequently return a stale authority snapshot as ready.

## Fix Focus Areas
- services/gateway/src/command-forwarder.ts[205-287]
- services/gateway/src/ownership.ts[228-284]

## Recommended Fix
Associate reload debt and in-flight rehydration with an ownership generation or fencing token rather than only the game ID. Clear debt only when the completed reload still belongs to the current generation, invalidate pending generations on release, and recheck valid ownership after rehydration before returning true.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Finished games retain bot subscriptions ✓ Resolved 🐞 Bug ☼ Reliability
Description
EngineBotMover.registerGame removes a non-owner's subscription only when its handler receives an
ended broadcast, even though that replica's cached authority never becomes terminal. If Redis
drops the fire-and-forget terminal publish or the terminal event is sent before the asynchronous
subscription is active, no later event is guaranteed to unregister the completed game, so each such
game remains retained for the process lifetime.
Code

services/gateway/src/engine-bot.ts[R103-105]

+        if (msg.t === 'ended') {
+          this.unregisterGame(gameId);
+          return;
Relevance

●● Moderate

Message-loss cleanup concern is plausible, but the change explicitly relies on terminal broadcasts
and adds integration coverage.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The new branch makes receipt of ended the only terminal cleanup signal for a stale non-owner
replica, while the pub/sub implementation deliberately neither retries failed publishes nor waits
for subscription activation. Helm now activates this mover on every gateway replica by default,
making the previously dormant multi-replica lifecycle path active in standard chart deployments.

services/gateway/src/engine-bot.ts[100-106]
packages/realtime-gateway/src/pubsub.ts[181-188]
packages/realtime-gateway/src/pubsub.ts[206-217]
packages/realtime-gateway/src/authority.ts[414-418]
deploy/helm/gambit/values.yaml[139-146]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A non-owner's engine mover depends solely on an `ended` pub/sub message to unsubscribe, but Redis pub/sub is explicitly best-effort and subscription activation is asynchronous. Add a cleanup path that does not require receipt of that individual terminal broadcast, such as reconciling registered bot games with durable game state or explicitly unregistering when the gateway drops its final local room; retain the broadcast handler as the fast path.

## Fix Focus Areas
- services/gateway/src/engine-bot.ts[100-106]
- packages/realtime-gateway/src/pubsub.ts[181-217]
- packages/realtime-gateway/src/gateway.ts[315-326]

## Recommended Fix
Introduce a reliable lifecycle/reconciliation mechanism for mover registrations so a missed `ended` message cannot leave a game subscribed indefinitely. Wire it to remove the mover when the local gateway no longer serves the game and/or periodically or on a durable lifecycle callback verify terminal state; add a Redis-pub/sub failure or subscribe-race test that proves completed games are eventually unregistered.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational
3. Pod loss strands bot games ✓ Resolved 🐞 Bug ☼ Reliability
Description
EngineBotMover.doMove returns when prepareOwnership() finds the existing lease elsewhere and
schedules no retry for when that lease expires. If the owning pod dies while the bot is thinking and
a client reconnects before lease expiry, the one registration attempt returns and no subsequent
broadcast exists to wake any replica, leaving that game at the bot's turn indefinitely.
Code

services/gateway/src/engine-bot.ts[163]

+      if (this.ownership && !(await this.ownership.prepareOwnership(gameId))) return;
Relevance

● Weak

The PR explicitly documents this unchanged lease-expiry limitation and defers timer-driven retry as
follow-up.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Registration performs one immediate attempt and otherwise relies entirely on game broadcasts; the
new false return does not enqueue another attempt. The ownership registry only renews leases already
held locally, while command broadcasts are emitted only after an applied command, so an owner crash
produces no event that can wake the waiting mover after the Redis lease expires.

services/gateway/src/engine-bot.ts[98-115]
services/gateway/src/engine-bot.ts[124-179]
services/gateway/src/ownership.ts[302-350]
packages/realtime-gateway/src/authority.ts[388-429]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Bot games can remain permanently stuck when the owning gateway dies and another replica checks ownership before the abandoned lease expires. The failed ownership preparation has no timer, ownership callback, or other trigger that retries after expiration.

## Fix Focus Areas
- services/gateway/src/engine-bot.ts[124-179]
- services/gateway/src/ownership.ts[254-284]

## Recommended Fix
Schedule a bounded, cancellable retry whenever ownership preparation returns false, using lease-aware delay or backoff to avoid busy polling. Keep at most one retry per subscribed game, cancel it on unregister or shutdown, and rerun through the existing serialized `attemptMove` path so only the successful claimant invokes the engine.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Results up to commit fc810f4 🧠 Deep


🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)


Action required
1. Spectators create permanent replay load ✓ Resolved 🐞 Bug ➹ Performance
Description
scheduleRecheck reloads and fully replays a registered game from the durable log every lease
interval whenever this replica is not the owner. Because registration is triggered by a join but
survives session departure until the game ends, visitors to abandoned or unlimited bot games leave
permanent database and CPU work that grows with both game count and history length.
Code

services/gateway/src/engine-bot.ts[R292-295]

+    const timer = setTimeout(() => {
+      this.rechecks.delete(gameId);
+      this.reloadBeforeNextPass.add(gameId);
+      void this.attemptMove(gameId);
Relevance

●●● Strong

The PR explicitly acknowledges this lifetime replay cost as a remaining limit requiring follow-up.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
A completed join invokes the gateway's game-loaded callback, which registers the bot mover,
including for anonymous spectators. The new timer marks every non-owner pass for reloadFromLog,
while the mover only unregisters on terminal or non-bot state; reloadFromLog reads the complete
event log and rebuilds the aggregate, and the amendment itself acknowledges that one spectator visit
retains this polling for the game's lifetime with replay cost growing quadratically.

packages/realtime-gateway/src/gateway.ts[157-166]
packages/realtime-gateway/src/gateway.ts[208-215]
services/gateway/src/serve.ts[529-536]
services/gateway/src/engine-bot.ts[178-187]
services/gateway/src/engine-bot.ts[282-299]
packages/realtime-gateway/src/authority.ts[216-239]
docs/adr/0080-engine-bot-opponent.md[78-80]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The non-owner recovery timer performs a full durable-log replay every lease interval for every bot game ever registered on the replica. Registrations are not removed when local interest disappears, allowing abandoned games and transient spectator joins to accumulate persistent database and CPU load.

## Fix Focus Areas
- services/gateway/src/engine-bot.ts[282-299]
- packages/realtime-gateway/src/gateway.ts[208-215]
- services/gateway/src/serve.ts[529-536]

## Recommended Fix
Tie non-owner recovery registration to active local game interest and unregister when the last local session leaves, while retaining ownership recovery for games that still have local participants. Avoid full-history reloads merely to test whether another owner remains alive; use a lightweight durable terminal-state or incremental-event check, and perform the full reload only after this replica successfully claims ownership.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

@greptile-apps

greptile-apps Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

[High risk] Gateway ownership and bot decision logic for multi-node routing.

The PR appears safe to merge based on the reviewed changes.

Summary

The PR enables the engine bot in Helm and restricts bot decisions to the game-owning gateway replica. The latest revision extracts the Redis pub/sub connection options into a tested helper without changing their behavior.

  • The earlier bot-turn, subscription-lifecycle, and Redis startup findings are addressed in the current code.
  • No new actionable issue was established in the changes since the previous review.

Reviews (7) · Last reviewed commit: "test(gateway): pin the pub/sub connectio..."

Comment thread services/gateway/src/command-forwarder.ts Outdated
Comment thread services/gateway/src/engine-bot.ts
Comment thread services/gateway/src/engine-bot.ts Outdated
…t-parity

# Conflicts:
#	docs/PROJECT_STATE.md
- Reload debt belongs to the claim that recorded it. rehydrateIfStale tracked
  in-flight reloads by game only, so a slow reload begun for an earlier claim
  could finish after a later claim and mark a copy that predates another
  owner's moves as fresh. Claims now get a process-wide, never-reused number;
  only a reload for the current claim settles the debt. prepareOwnership
  re-checks the lease after reloading. (Qodo)
- A result dropped by the post-engine check is followed by one more pass, so a
  lease that lapses mid-think no longer strands the bot's turn. (Greptile P1)
- A copy that already shows the game over unregisters before any ownership
  traffic, and a non-owner re-checks every lease TTL, reloading its copy from
  the event log: a game whose owner died is claimed and continued, and a game
  whose ended broadcast was lost is unregistered. (Qodo x2)
- Tests: lapsed-lease re-run, orphaned-game re-check, missed-ended cleanup,
  finished-game join, claim-generation race and dead-owner takeover on real
  Redis. Test engine fakes yield like real I/O. A test-only Redis preflight
  fails the multi-node file in seconds when Redis is unreachable; the clients
  under test keep production retry settings.
- ADR-0080 amendment and PROJECT_STATE Increment 69 describe the final
  behaviour and its remaining limits.
…w notes

- OwnershipRegistry recorded a SET NX claim as continuing ownership whenever a
  failed renewal had left the game in its owned set. If the lease had lapsed
  and another node advanced the game in between, the re-claim fired no
  onClaimed, so nothing reloaded and the node acted on a stale copy (route()
  and the bot alike). A SET NX that creates the key is now always a new claim;
  renewing a key still held is not. Found by the independent Gemini review.
- stillCurrent drops its turn check: the same ply and FEN already encode the
  side to move, so the clause could never fail on its own. Its comment now
  says the apply can queue behind an earlier append and that the event log's
  expected-sequence check is the last line of defence.
- Helm refuses a non-boolean gateway.engineBot.enabled: a quoted "false" is
  truthy and would have left the bot on. Snapshot pins the rejection.
- Tests: real-Redis re-claim after an unnoticed lapse; wider negative window
  and wait deadlines for slow runners.
- Docs: ADR-0080 wording (reload ordering, append check, costs of non-owner
  re-checks, the pre-existing route() lease re-check left as follow-up),
  chart README engine footprint, RUNNING.md prose, PROJECT_STATE Increment 69.
…ten docs

- Real-Redis test: a replica whose copy still shows a game in progress claims
  it after the owner resigned the game and died; only the post-claim reload
  shows the ending, and the mover must unregister without an engine call. The
  post-reload `status.over` check had no test isolating it.
- Docs, from the final architecture review: the rehydrateIfStale comment and
  ADR-0080 say the new reload starts at once and queues on the game's lock
  (for a cached game, which is every path here); chart README corrects the
  engine footprint (no worker until the first bot move; a 300 ms time-limited
  search means a throttled pod plays weaker, not slower); ADR-0010 notes the
  changed claim meaning; RUNNING.md no longer says the failure is silent;
  ADR-0080 and PROJECT_STATE list the new tests and eleven mutation checks.
@sayed710

Copy link
Copy Markdown
Owner Author

/review

Comment thread services/gateway/src/engine-bot.ts
@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit fc810f4

Comment thread services/gateway/src/engine-bot.ts
…s leave

Qodo and Greptile (on fc810f4): a non-owner's re-check reloaded the game from
the event log every lease TTL, and a registration outlived the session that
caused it, so one spectator visit made that replica replay a game's full
history every TTL until the game ended.

- RealtimeGateway gains an optional onGameUnloaded hook, fired when the last
  session in a game leaves a node (the pair of onGameLoaded).
- The mover unregisters there unless the registry still lists the game as this
  node's (claimedHere, true even while renewals fail): a non-owner exists only
  to take over for a local player; the owner keeps the game for a player who
  reconnected through another replica.
- The non-owner re-check no longer reloads: it is one ownership attempt, and a
  successful claim reloads through the router's takeover path.
- Tests: session-leave rule (unit and real Redis, where the owner still moves
  for a player on the other replica) and the gateway hook. Mutations M1-M7 and
  M9-M13 rerun one at a time on this code; each is caught.
- Docs: ADR-0080 and PROJECT_STATE updated; RUNNING.md now says exactly what
  is logged in each misconfiguration; chart README no longer claims memory
  headroom from hash size alone; claim() doc comment moved back to claim().
@sayed710

Copy link
Copy Markdown
Owner Author

/review

Comment thread services/gateway/src/engine-bot.ts Outdated
@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 1354e38

Comment thread services/gateway/src/engine-bot.ts Outdated
Comment thread services/gateway/src/engine-bot.ts
…ot a snapshot

Found on 1354e38 by the architecture and concurrency reviews, Qodo and Greptile:
localSessionsGone unregistered when the registry did not yet list the game, but
a claim could be in flight (the mover's own prepareOwnership, or route() for a
command sent just before disconnecting). The claim then succeeded, leaving an
owner that renews the lease forever with no mover: the bot never moved again
and no other replica could claim the game.

- localSessionsGone now only marks the game as having no local player and takes
  a pass; the game is dropped when, and only when, a pass fails to own it. A
  join clears the mark. A claim in flight that succeeds keeps the game, and a
  former owner lets go on its first failed claim. claimedHere/listsAsOwned are
  gone.
- The non-owner re-check reloads its copy again, so a connected non-owner that
  missed `ended` lets go within one lease TTL (Greptile). Registrations are now
  bounded by local sessions, so the replay runs only while someone on that
  replica is in the game.
- Tests: claim in flight at session leave, former owner, rejoin, and the
  restored missed-ended re-check. Fifteen mutations, each introduced alone on
  the compiled build, are all caught.
- Docs: ADR-0080 and PROJECT_STATE (scope now names packages/realtime-gateway).
@sayed710

Copy link
Copy Markdown
Owner Author

/review

@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 84bba34

…redis's ready check

CI run 36203300566 attempt 1 failed "a finished game stops bot work on every
replica" (timed out waiting for both movers to let go) after ioredis logged
"Connection in subscriber mode, only subscriber commands may be used" from
_readyCheck. ioredis writes SUBSCRIBE during the `connect` phase (it carries
Redis's `loading` flag), so a subscription made within milliseconds of
connecting switches the connection to subscriber mode before the ready check's
INFO, which is then refused; ioredis resets the connection and the `ended`
broadcast published in that gap is lost. Test nodes subscribe right after
connecting, unlike a gateway whose rooms open long after startup, and a loaded
runner widens the gap (the attempt-2 re-run passed; it never reproduced
locally). The harness now passes enableReadyCheck: false to its nodes'
createRedisPubSub; production pub/sub code and settings are unchanged.
@sayed710

Copy link
Copy Markdown
Owner Author

/review

@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 37fdfac

Comment thread services/gateway/test/engine-bot-multinode.integration.test.ts Outdated
…nnection

Greptile (on 37fdfac): the multi-replica suite avoided the early-subscription
failure by disabling the ready check in the test only, while gateways run with
it enabled and do subscribe at startup (terminal-event workers). ioredis writes
SUBSCRIBE during the handshake (it is allowed while Redis loads), so a
subscription made right after connecting — startup, a reconnect — switches the
connection to subscriber mode before the ready check's INFO, which Redis
refuses; ioredis resets the connection and drops what was published
meanwhile. createRedisPubSub now disables the check on the subscriber
connection only (it waits for dataset loading, which pub/sub does not need);
the publisher keeps it. The test override is removed, so the suite runs the
production configuration.

Also, from the final delegated reviews on 37fdfac:
- Test: a claim still in flight when the last local session leaves, which then
  fails, lets the game go (the in-flight case only covered success before).
- Docs: a pass that fails outright (Redis or the event log unreachable)
  schedules a re-check regardless of local players; ADR-0080, PROJECT_STATE
  and the scheduleRecheck comment now say so.
@sayed710

Copy link
Copy Markdown
Owner Author

/review

@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit e728015

…e ADR-0008

From the delegated reviews of e728015:
- Test quality: the subscriber ready-check fix had no direct test (the race
  appeared once on CI and does not reproduce on demand). createRedisPubSub now
  builds its options through an exported pubSubConnectionOptions, and
  redis-pubsub-options.test.ts pins that only the subscriber skips the ready
  check, that the publisher keeps ioredis's default, that caller options pass
  through, and that a caller cannot re-enable the check on the subscriber.
  Removing the subscriber option fails both tests.
- Architecture (LOW): ADR-0008 now points to the ADR-0080 amendment.
@sayed710

Copy link
Copy Markdown
Owner Author

/review

@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit a38b9ce

@sayed710
sayed710 merged commit 0b8099f into main Sep 26, 2026
12 checks passed
@sayed710
sayed710 deleted the claude/helm-engine-bot-parity branch September 26, 2026 04:28
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