Skip to content

fix: fleet-restart lock — ownership-checked lease refresh + acquire inside try/finally (#1919) - #1928

Merged
obasilakis merged 1 commit into
devfrom
feature/1919-fleet-lock-hardening
Jul 31, 2026
Merged

fix: fleet-restart lock — ownership-checked lease refresh + acquire inside try/finally (#1919)#1928
obasilakis merged 1 commit into
devfrom
feature/1919-fleet-lock-hardening

Conversation

@webmixgamer

Copy link
Copy Markdown
Contributor

Summary

  • The ops:fleet_restart per-iteration lease refresh was a bare EXPIRE gated only on a local flag: after a TTL lapse it would extend a concurrent caller's lease while both fleet loops ran — the exact dual-run the lock exists to prevent, silently. The refresh is now an ownership-checked pre-action gate (GET-compare via the new shared redis_breaker_util.lock_token_matches, same predicate as the release so the two can't drift).
  • Foreign token ⇒ stop the run with honest partial accounting: summary/audit gain processed + stopped_early="lease_lost_foreign"; completed restarts stand; the release compare-and-delete still runs (foreign-safe by construction — skipping it on a false "absent" would self-lockout the fleet for the TTL). Absent token (incl. EXPIRE→0 in the GET→EXPIRE sliver) ⇒ SETNX re-acquire and continue — nothing is racing us, so a destructive half-done run completes instead of aborting; lease_reacquired is audited. Refresh Redis errors stay fail-open with one throttled warning per run.
  • agents = list_all_agents_fast() moved inside the try/finally (pre-initialized for the finally-audit read), so nothing can ever sit between lock acquire and release; an abnormal exit audits as stopped_early="error" + exception class name only (fix: fleet restart adopts rebuilt base images through the canonical lifecycle path (#1860) #1912 exposure rule), distinguishing an aborted run from an empty fleet.
  • TTL 900→2100, sized above the slowest single agent: the restart path runs skill injection, itself bounded at skill_service._INJECT_LOCK_TTL_SECONDS (1800s) — at 900s a mid-agent lapse was arithmetically guaranteed on the slow path, and detection alone would have shipped against a guaranteed trip. Constants are comment-linked so retuning either side re-opens the math visibly.

Scope notes

Changes

  • src/backend/routers/ops.py — ownership gate, re-acquire, TTL resize, acquire-inside-try, partial-run honesty fields (response summary + audit details)
  • src/backend/redis_breaker_util.pylock_token_matches (dual bytes/str, documented as belt-and-braces under decode_responses=True)
  • tests/unit/test_1860_fleet_restart_adoption.py — +11 tests (27 total): 2-agent happy-path lease baseline, foreign stop + release-leaves-foreign, absent re-acquire win/lose, refresh-error scoped fail-open with release still deleting, EXPIRE→0 / EXPIRE-raise, first-iteration loss, bytes token, no-token-values-in-logs, listing-failure releases lock + error audit
  • Docs: architecture.md Redis-catalog bullet, agent-lifecycle.md (single-flight section, tests line, changelog row, line refs re-verified), mobile-admin-pwa.md, feature-flows.md index row, 2 learnings.md entries (lease-TTL-vs-slowest-iteration; false-detection-vs-self-safe-cleanup)
  • Security: /cso --diff PASS, zero findings — report pair committed under docs/security-reports/

Test Plan

  • pytest tests/unit/test_1860_fleet_restart_adoption.py — 27/27
  • Live suite tests/test_ops.py against the running stack — 41/41
  • Adjacent test_dispatch_breaker.py + test_69_ephemeral_agents.py — 66/66
  • Live validation on a real 4-agent stack: baseline (TTL 2098 observed mid-run, lock released), foreign-takeover mid-run (partial stop, foreign token untouched by release, warning + audit row), absent-lease mid-run (re-acquired, run completed, lease_reacquired:true audited), concurrent call → 409, fleet healthy after

Fixes #1919

🤖 Generated with Claude Code

…ire inside try/finally (#1919)

The per-iteration refresh was a bare EXPIRE gated on a local flag — after a
TTL lapse it extended a concurrent caller's lease while both loops ran. The
refresh is now a pre-action ownership gate (GET-compare via the shared
redis_breaker_util.lock_token_matches): a foreign token stops the run with
honest partial accounting (summary/audit gain processed + stopped_early), an
absent token is re-acquired via SETNX so an unraced run completes instead of
aborting, EXPIRE→0 routes to the absent path, and refresh Redis errors stay
fail-open with one throttled warning per run. list_all_agents_fast() moved
inside the try/finally so nothing can ever leak the lock between acquire and
release; an abnormal exit audits as stopped_early="error" + exception class
name only. TTL 900→2100, sized above the slowest single agent (skill
injection alone is bounded at 1800s) so a mid-agent lapse is no longer
arithmetically guaranteed. Release stays compare-and-delete, attempted even
after detected loss (foreign-safe by construction).

+11 unit tests (27 total in the file); live-validated on the local stack
(foreign takeover mid-run, absent re-acquire to completion, 409 concurrency,
TTL 2098 observed, release verified). Sibling hand-rolled lock sites and the
system_seed_service unconditional release → #1920.

Fixes #1919

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@obasilakis obasilakis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validated via /validate-pr: docs, security, packaging, test adequacy and lock-logic correctness all pass. All 21 CI checks green. Approving.

@obasilakis
obasilakis merged commit 8e92452 into dev Jul 31, 2026
22 checks passed
@obasilakis
obasilakis deleted the feature/1919-fleet-lock-hardening branch July 31, 2026 14:26
AndriiPasternak31 added a commit that referenced this pull request Jul 31, 2026
…entries

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

2 participants