epic-5/story-2: Add Post-MVP Automatic Config Refresh - #44
Conversation
There was a problem hiding this comment.
Code Review
This pull request implements an opt-in automatic config refresh feature using a background daemon thread with mutex-protected atomic snapshot swaps, exponential backoff, and lifecycle event emissions. The review feedback highlights critical resilience issues, specifically potential OverflowError crashes during backoff calculations under persistent failures, and a race condition in the thread shutdown logic that could spawn duplicate active daemon threads.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| backed_off = self._policy.interval_seconds * ( | ||
| self._policy.backoff_factor ** self._consecutive_failures | ||
| ) | ||
| return backed_off >= self._policy.backoff_max_seconds |
There was a problem hiding this comment.
Under persistent network or endpoint failures, self._consecutive_failures will grow indefinitely. When calculating self._policy.backoff_factor ** self._consecutive_failures, this will eventually raise an OverflowError (e.g., 2.0 ** 1024 overflows in Python), crashing the background daemon thread and preventing any future retries. Catching OverflowError ensures the worker remains resilient and correctly caps the backoff at backoff_max_seconds.
try:
backed_off = self._policy.interval_seconds * (
self._policy.backoff_factor ** self._consecutive_failures
)
return backed_off >= self._policy.backoff_max_seconds
except OverflowError:
return True| if self._consecutive_failures > 0: | ||
| backed_off = base * (self._policy.backoff_factor ** self._consecutive_failures) | ||
| base = min(backed_off, self._policy.backoff_max_seconds) |
There was a problem hiding this comment.
Similarly to _at_terminal_backoff, calculating the exponential backoff here can raise an OverflowError if self._consecutive_failures grows large. Catching OverflowError and falling back to backoff_max_seconds prevents the daemon thread from crashing.
if self._consecutive_failures > 0:
try:
backed_off = base * (self._policy.backoff_factor ** self._consecutive_failures)
base = min(backed_off, self._policy.backoff_max_seconds)
except OverflowError:
base = self._policy.backoff_max_seconds| thread = self._thread | ||
| if ( | ||
| thread is not None | ||
| and thread.is_alive() | ||
| and thread is not threading.current_thread() | ||
| ): | ||
| thread.join(timeout=timeout) | ||
| self._thread = None |
There was a problem hiding this comment.
Setting self._thread = None at the end of stop() before the thread has actually exited (or when called re-entrantly from the worker thread itself where join() is skipped) creates a race condition. If start() is called immediately after, it will see self._thread as None, clear self._stopping, and spawn a new thread. The old thread, seeing self._stopping cleared, will continue running, resulting in duplicate active daemon threads. Keeping the reference to self._thread if it is still alive prevents start() from clearing self._stopping and spawning a duplicate thread.
| thread = self._thread | |
| if ( | |
| thread is not None | |
| and thread.is_alive() | |
| and thread is not threading.current_thread() | |
| ): | |
| thread.join(timeout=timeout) | |
| self._thread = None | |
| thread = self._thread | |
| if ( | |
| thread is not None | |
| and thread.is_alive() | |
| and thread is not threading.current_thread() | |
| ): | |
| thread.join(timeout=timeout) | |
| if not thread.is_alive(): | |
| self._thread = None |
13f7556 to
87786af
Compare
b99ceaa to
65349c2
Compare
87786af to
331edf0
Compare
Beads: ai-driven-product-dev-9hwq Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Beads: ai-driven-product-dev-9hwq Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Beads: ai-driven-product-dev-577c Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…— implementation (GREEN) Beads: ai-driven-product-dev-577c Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…t — tests (RED) Beads: ai-driven-product-dev-usaq Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…king re-point — implementation (GREEN) Beads: ai-driven-product-dev-usaq Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Beads: ai-driven-product-dev-eugx Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…rier fragment Beads: ai-driven-product-dev-5r84 Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…shed coverage) - stop() skips self-join when called from worker thread (deadlock guard) - remove pragma; test refresh.worker_crashed guard + re-entrant stop Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
65349c2 to
8a6badc
Compare
F-066 propagation (rebase onto remediated 3-3)Rebased
|
|
Superseded — all commits already in main (bc76b64). Closing without merge as part of post-sprint cleanup. |
Story 5.2 — Add Post-MVP Automatic Config Refresh
Part of sprint
sprint/2026-04-06-convert-python-sdk. Stacked on epic-5/story-1 (PR #43 → … → Epic 3 chain).Implements FR31 (post-MVP, opt-in automatic config refresh). MVP behavior is byte-for-byte unchanged when
refresh=None(the default).What was built
RefreshConfig(frozen dataclass) onSDKConfig.refresh: Optional[RefreshConfig] = None, with__post_init__validation. Opt-in only — default spins up no daemon thread, emits no events, adds no cost.ConfigRefresherdaemon-thread worker (config_loader/refresh.py) — reuses the existing Story 1.2TransportProtocol and the existingload_snapshotpipeline (no parallel transport). Interruptible sleep, deterministic test seams (no wall-clock timing in tests).Core(mutex-protected) so in-flight evaluations always see a single coherent snapshot; long-livedContextobjects retain their creation-time snapshot.Core.refresh_now(),Core.close(), and context-manager protocol added.refresh.start/success/skipped/fail/worker_crasheddiagnostics.TrackingQueue.update_snapshot_metadata()re-points account/project IDs on swap;LifecycleEvent.CONFIG_UPDATEDfires on each successful refresh.RefreshConfig+ direct config emitsrefresh.skippedinstead of silently ignoring.docs/initialization.mdrefresh-policy section;docs/migration-from-javascript.mdunit/default-divergence mapping; towncrier fragment.ADR gate (audit F-027 + F-028)
docs/adr/0001-config-refresh-concurrency-and-backoff.mdratifies the mutex-protected swap (atomic w.r.t. an evaluating caller, safe under free-threaded CPython 3.13+ — not relying on GIL atomicity) and every concrete backoff/interval/jitter value (interval 300s, jitter 30s, factor 2.0, max 600s).Audit findings addressed
Tests
762 tests pass (was 732; +30 in
tests/test_config_refresh.py).scripts/verify_release.pyall gates green (ruff, mypy --strict, coverage ≥85% / evaluation ≥95%, parity suite, towncrier draft, uv build).Beads
epic
ai-driven-product-dev-14vo; tasks-9hwq/-577c/-usaq/-eugx/-5r84(all closed).Sprint-mode notes
stop()self-join deadlock guard +worker_crashedcoverage; round 2 clean).🤖 Generated with Claude Code