Skip to content

Commit 7fc338d

Browse files
committed
fix: lead-estimate heartbeat (v2.1) — effective TTL, key reuse, permanent stops
Adversarial self-review of the alternate-beat fix found confirmed inward-drift liveness hazards: at steady state every attempt fired at exactly ttl/2 lead so one failed extend put the retry at lead 0; the 1s interval floor guaranteed lapse for spec-legal ttl in (1000,2000); and sleep-after-response re-arming slipped beats by one RTT per cycle. The heartbeat now estimates its remaining lead from the AUTHORITATIVE expires_at_ms the server returns, compared clock-skew-free (server-frame differences plus client-monotonic elapsed only), extending by ttl when lead < 1.5*ttl and skipping otherwise. Failures retry next beat with the SAME idempotency key (a lost-response extend cannot double-apply); permanent codes (RESERVATION_EXPIRED / RESERVATION_FINALIZED / MAX_EXTENSIONS_EXCEEDED / TENANT_CLOSED / NOT_FOUND, or status 410) stop the heartbeat for good; the 1s interval floor is removed. Spec-review round: tenant policy max_reservation_ttl_ms (default 1h) silently caps granted TTLs and the create response exposes no effective TTL — seeding from the requested ttl could schedule the first beat hours after expiry. The client now captures the HTTP Date header (CyclesResponse.server_date_ms) and seeds the heartbeat from effective_ttl = clamp(expires_at_ms - Date, 1000, requested), still skew-free (server-frame difference). All four heartbeats (sync/async lifecycle, sync/async streaming) share the design. Spec guidance updated in cycles-protocol#148 (02d1270). 531 tests pass at 100% coverage; ruff and mypy --strict clean.
1 parent 0e76598 commit 7fc338d

7 files changed

Lines changed: 626 additions & 95 deletions

File tree

AUDIT.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,22 @@
1313

1414
---
1515

16-
## 2026-07-27 — Heartbeat drift fix + actual_source marker (v0.5.1)
16+
## 2026-07-27 — Heartbeat lead-estimate redesign + actual_source marker (v0.5.1)
1717

1818
The heartbeat extended by full ttl_ms every ttl/2 beat while extend_by_ms
1919
is relative to current expiry — drifting expiry outward +ttl/2 per beat
2020
(zombie budget lockup on kill; max_extensions burned 2× too fast). All
2121
four heartbeats now alternate-beat extend (lead stays [ttl/2, 1.5×ttl]).
22+
Self-review found the first fix (alternate-beat) introduced inward-drift
23+
hazards (single-failure lead-0, sub-2s-ttl floor decay, RTT slippage); the
24+
heartbeat now runs on a clock-skew-free lead estimate from the
25+
authoritative expires_at_ms, derives the effective TTL from the Date
26+
header (tenant max_reservation_ttl_ms caps grants — default 1h), reuses
27+
the extend idempotency key on retries, and stops permanently on
28+
expired/finalized/max-extensions/tenant-closed/not-found.
2229
Commits whose actual was defaulted from the estimate now carry
2330
metadata.actual_source="estimate" for audit honesty. Spec guidance:
24-
cycles-protocol#148. 517 tests pass at 100% coverage.
31+
cycles-protocol#148. 531 tests pass at 100% coverage.
2532

2633
## 2026-07-27 — Durable commit retries (journal + /v1/events fallback)
2734

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Fixed
1111

12-
- **Heartbeat extend drift**: `extend_by_ms` is relative to the reservation's *current* `expires_at_ms` (spec), but the heartbeat extended by the full `ttl_ms` on every `ttl/2` beat — drifting expiry outward by `ttl/2` per beat. A killed process left its reserved budget locked until the drifted expiry (a zombie-reservation window scaling with runtime, bounded only by `max_extensions`), and long runs burned the `max_extensions` budget twice as fast as needed, losing heartbeat protection mid-flight. All four heartbeats (both lifecycles, both streaming context managers) now use alternate-beat extension: extend on the first beat and every second beat after a success, retrying immediately after a failure. Expiry lead stays within `[ttl/2, 1.5×ttl]`; extension consumption is halved. Fleet-wide fix (TS/Java/Rust ship the same change); spec guidance added in cycles-protocol#148.
12+
- **Heartbeat redesign (lead-estimate)**: the initial alternate-beat fix traded outward drift for inward-drift liveness hazards (single-failure lead-0 retry, guaranteed lapse for ttl < 2s under the old 1s interval floor, RTT slippage) found by adversarial self-review. The heartbeat now estimates its remaining lead from the authoritative `expires_at_ms` the server returns — compared clock-skew-free (server-frame differences + client-monotonic elapsed only) — and extends when lead < 1.5×ttl. It also: derives the **effective TTL** from `expires_at_ms − Date` header (tenant policy `max_reservation_ttl_ms`, default 1h, silently caps grants — a 24h request would otherwise heartbeat 12h late); reuses the same idempotency key when retrying a failed extend (a lost response cannot double-extend); stops permanently on `RESERVATION_EXPIRED`/`RESERVATION_FINALIZED`/`MAX_EXTENSIONS_EXCEEDED`/`TENANT_CLOSED`/`NOT_FOUND`; and drops the 1s interval floor. Spec guidance: cycles-protocol#148.
13+
- **Heartbeat extend drift** (superseded by the redesign above, kept for history): `extend_by_ms` is relative to the reservation's *current* `expires_at_ms` (spec), but the heartbeat extended by the full `ttl_ms` on every `ttl/2` beat — drifting expiry outward by `ttl/2` per beat. A killed process left its reserved budget locked until the drifted expiry (a zombie-reservation window scaling with runtime, bounded only by `max_extensions`), and long runs burned the `max_extensions` budget twice as fast as needed, losing heartbeat protection mid-flight. All four heartbeats (both lifecycles, both streaming context managers) now use alternate-beat extension: extend on the first beat and every second beat after a success, retrying immediately after a failure. Expiry lead stays within `[ttl/2, 1.5×ttl]`; extension consumption is halved. Fleet-wide fix (TS/Java/Rust ship the same change); spec guidance added in cycles-protocol#148.
1314

1415
### Added
1516

runcycles/client.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,14 @@ def _extract_idempotency_key(body: dict[str, Any]) -> str | None:
4141
return body.get("idempotency_key")
4242

4343

44-
_RESPONSE_HEADERS = ("x-request-id", "x-ratelimit-remaining", "x-ratelimit-reset", "x-cycles-tenant", "retry-after")
44+
_RESPONSE_HEADERS = (
45+
"x-request-id",
46+
"x-ratelimit-remaining",
47+
"x-ratelimit-reset",
48+
"x-cycles-tenant",
49+
"retry-after",
50+
"date",
51+
)
4552

4653
_BALANCE_FILTER_PARAMS = {"tenant", "workspace", "app", "workflow", "agent", "toolset"}
4754

runcycles/lifecycle.py

Lines changed: 122 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
from runcycles.retry import (
4141
AsyncCommitRetryEngine,
4242
CommitRetryEngine,
43+
_extract_error_code,
4344
_is_recognized_rejection,
4445
)
4546

@@ -198,6 +199,49 @@ def _build_release_body(reason: str) -> dict[str, Any]:
198199
return {"idempotency_key": str(uuid.uuid4()), "reason": reason}
199200

200201

202+
def _now_mono_ms() -> float:
203+
"""Monotonic milliseconds — the heartbeat's only clock (test seam)."""
204+
return time.monotonic() * 1000.0
205+
206+
207+
# Extend failures that can never succeed again — the heartbeat stops on them.
208+
_PERMANENT_EXTEND_CODES = frozenset(
209+
{
210+
"RESERVATION_EXPIRED",
211+
"RESERVATION_FINALIZED",
212+
"MAX_EXTENSIONS_EXCEEDED",
213+
"TENANT_CLOSED", # closure is irreversible per cascade semantics
214+
"NOT_FOUND", # a purged reservation never comes back
215+
}
216+
)
217+
218+
219+
def _effective_ttl_ms(
220+
requested_ttl_ms: int,
221+
expires_at_ms: int | None,
222+
server_date_ms: int | None,
223+
) -> int:
224+
"""The TTL the server actually granted, best-effort.
225+
226+
Tenant policy ``max_reservation_ttl_ms`` (default 1h) silently caps the
227+
granted TTL, and the create response has no effective-TTL field —
228+
scheduling the heartbeat from the REQUESTED ttl can put the first beat
229+
long after expiry. Derive the grant from two server-frame values —
230+
``expires_at_ms`` minus the HTTP ``Date`` header — which stays
231+
clock-skew-free (the header's ~1s resolution is negligible against
232+
multi-second TTLs). Falls back to the requested ttl when either value
233+
is unavailable.
234+
"""
235+
if expires_at_ms is None or server_date_ms is None:
236+
return requested_ttl_ms
237+
derived = expires_at_ms - server_date_ms
238+
return max(1000, min(derived, requested_ttl_ms))
239+
# Lead threshold: extend when the estimated remaining lifetime drops below
240+
# this multiple of ttl. Attempts then happen with ~ttl of margin, tolerating
241+
# failed beats; the success-path lead stays within ~[ttl, 2*ttl].
242+
_LEAD_TARGET_FACTOR = 1.5
243+
244+
201245
def _build_extend_body(ttl_ms: int) -> dict[str, Any]:
202246
validate_extend_by_ms(ttl_ms)
203247
return {"idempotency_key": str(uuid.uuid4()), "extend_by_ms": ttl_ms}
@@ -354,7 +398,8 @@ def execute(
354398

355399
# Start heartbeat
356400
heartbeat_stop = threading.Event()
357-
heartbeat_thread = self._start_heartbeat(reservation_id, cfg.ttl_ms, ctx, heartbeat_stop)
401+
hb_ttl = _effective_ttl_ms(cfg.ttl_ms, res_result.expires_at_ms, res_response.server_date_ms)
402+
heartbeat_thread = self._start_heartbeat(reservation_id, hb_ttl, ctx, heartbeat_stop)
358403

359404
try:
360405
result = fn(*args, **kwargs)
@@ -474,31 +519,61 @@ def _start_heartbeat(
474519
) -> threading.Thread | None:
475520
if ttl_ms <= 0:
476521
return None
477-
interval_s = max(ttl_ms / 2, 1000) / 1000.0
522+
# No 1s floor: for spec-legal ttl < 2000 a floored interval cannot
523+
# keep the reservation alive (each extend adds only ttl of lifetime).
524+
interval_s = (ttl_ms / 2) / 1000.0
478525

479526
def heartbeat_loop() -> None:
480-
# Alternate-beat extension: extend_by_ms is relative to the
481-
# CURRENT expiry (spec), so extending by ttl on every ttl/2 beat
482-
# drifts expiry outward +ttl/2 per beat — a zombie-reservation
483-
# window — and burns max_extensions twice as fast as needed.
484-
# Extend on the first beat (only ttl/2 of lifetime remains) and
485-
# every second beat after a success; retry right away after a
486-
# failure. Expiry lead stays within [ttl/2, 1.5*ttl].
487-
beats_since_extend = 1
527+
# Lead-estimate heartbeat: extend_by_ms is relative to the
528+
# CURRENT expiry (spec), so blind cadence-based extension either
529+
# drifts expiry outward or leaves zero margin after a failed
530+
# beat. Instead, estimate the remaining lead from the
531+
# AUTHORITATIVE expires_at_ms the server returns, compared
532+
# skew-free: server-frame differences plus client-monotonic
533+
# elapsed only (never client wall clock vs server wall clock).
534+
# Extend when lead < 1.5*ttl; skip otherwise. Failed extends are
535+
# retried with the SAME body (same idempotency key) so a lost
536+
# response cannot double-extend; permanent rejections stop the
537+
# heartbeat for good.
538+
initial_expiry = ctx.expires_at_ms
539+
known_expiry = initial_expiry
540+
anchor_ms = _now_mono_ms()
541+
pending_body: dict[str, Any] | None = None
488542
while not stop_event.wait(timeout=interval_s):
489-
beats_since_extend += 1
490-
if beats_since_extend < 2:
543+
elapsed = _now_mono_ms() - anchor_ms
544+
if initial_expiry is not None and known_expiry is not None:
545+
lead = (known_expiry - initial_expiry) + ttl_ms - elapsed
546+
else:
547+
lead = ttl_ms - elapsed
548+
if lead >= _LEAD_TARGET_FACTOR * ttl_ms:
491549
continue
492550
try:
493-
body = _build_extend_body(ttl_ms)
551+
body = pending_body if pending_body is not None else _build_extend_body(ttl_ms)
552+
pending_body = body
494553
response = self._client.extend_reservation(reservation_id, body)
495554
if response.is_success:
496-
beats_since_extend = 0
555+
pending_body = None
497556
new_expires = response.get_body_attribute("expires_at_ms")
498557
if new_expires is not None:
499558
ctx.update_expires_at_ms(int(new_expires))
559+
if initial_expiry is None:
560+
# Late anchor: treat this response as the frame origin.
561+
initial_expiry = int(new_expires)
562+
known_expiry = initial_expiry
563+
anchor_ms = _now_mono_ms()
564+
else:
565+
known_expiry = int(new_expires)
566+
elif known_expiry is not None:
567+
known_expiry += ttl_ms
500568
logger.debug("Heartbeat extend ok: id=%s", reservation_id)
501569
else:
570+
code = _extract_error_code(response)
571+
if response.status == 410 or code in _PERMANENT_EXTEND_CODES:
572+
logger.warning(
573+
"Heartbeat stopping permanently (%s, status=%d): id=%s",
574+
code, response.status, reservation_id,
575+
)
576+
return
502577
logger.warning("Heartbeat extend failed: id=%s, status=%d", reservation_id, response.status)
503578
except Exception:
504579
logger.warning("Heartbeat extend error: id=%s", reservation_id, exc_info=True)
@@ -577,7 +652,8 @@ async def execute(
577652
)
578653
_set_context(ctx)
579654

580-
heartbeat_task = self._start_heartbeat(reservation_id, cfg.ttl_ms, ctx)
655+
hb_ttl = _effective_ttl_ms(cfg.ttl_ms, res_result.expires_at_ms, res_response.server_date_ms)
656+
heartbeat_task = self._start_heartbeat(reservation_id, hb_ttl, ctx)
581657

582658
try:
583659
result = await fn(*args, **kwargs)
@@ -691,26 +767,50 @@ async def _handle_release(self, reservation_id: str, reason: str) -> None:
691767
def _start_heartbeat(self, reservation_id: str, ttl_ms: int, ctx: CyclesContext) -> asyncio.Task[None] | None:
692768
if ttl_ms <= 0:
693769
return None
694-
interval_s = max(ttl_ms / 2, 1000) / 1000.0
770+
# No 1s floor — see the sync heartbeat for rationale.
771+
interval_s = (ttl_ms / 2) / 1000.0
695772

696773
async def heartbeat_loop() -> None:
697-
# Alternate-beat extension — see the sync heartbeat for rationale.
698-
beats_since_extend = 1
774+
# Lead-estimate heartbeat — see the sync heartbeat for rationale.
775+
initial_expiry = ctx.expires_at_ms
776+
known_expiry = initial_expiry
777+
anchor_ms = _now_mono_ms()
778+
pending_body: dict[str, Any] | None = None
699779
try:
700780
while True:
701781
await asyncio.sleep(interval_s)
702-
beats_since_extend += 1
703-
if beats_since_extend < 2:
782+
elapsed = _now_mono_ms() - anchor_ms
783+
if initial_expiry is not None and known_expiry is not None:
784+
lead = (known_expiry - initial_expiry) + ttl_ms - elapsed
785+
else:
786+
lead = ttl_ms - elapsed
787+
if lead >= _LEAD_TARGET_FACTOR * ttl_ms:
704788
continue
705789
try:
706-
body = _build_extend_body(ttl_ms)
790+
body = pending_body if pending_body is not None else _build_extend_body(ttl_ms)
791+
pending_body = body
707792
response = await self._client.extend_reservation(reservation_id, body)
708793
if response.is_success:
709-
beats_since_extend = 0
794+
pending_body = None
710795
new_expires = response.get_body_attribute("expires_at_ms")
711796
if new_expires is not None:
712797
ctx.update_expires_at_ms(int(new_expires))
798+
if initial_expiry is None:
799+
initial_expiry = int(new_expires)
800+
known_expiry = initial_expiry
801+
anchor_ms = _now_mono_ms()
802+
else:
803+
known_expiry = int(new_expires)
804+
elif known_expiry is not None:
805+
known_expiry += ttl_ms
713806
else:
807+
code = _extract_error_code(response)
808+
if response.status == 410 or code in _PERMANENT_EXTEND_CODES:
809+
logger.warning(
810+
"Heartbeat stopping permanently (%s, status=%d): id=%s",
811+
code, response.status, reservation_id,
812+
)
813+
return
714814
logger.warning("Heartbeat extend failed: id=%s", reservation_id)
715815
except Exception:
716816
logger.warning("Heartbeat extend error: id=%s", reservation_id, exc_info=True)

runcycles/response.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
from dataclasses import dataclass, field
6+
from email.utils import parsedate_to_datetime
67
from typing import Any
78

89
from runcycles.models import ErrorResponse
@@ -73,6 +74,23 @@ def retry_after_ms_header(self) -> int | None:
7374
except ValueError:
7475
return None
7576

77+
@property
78+
def server_date_ms(self) -> int | None:
79+
"""HTTP ``Date`` header as epoch milliseconds (server wall clock).
80+
81+
Server-frame, so differencing it against other server-frame values
82+
(like ``expires_at_ms``) is clock-skew-free to within the header's
83+
one-second resolution plus transit latency. Returns ``None`` when
84+
absent or unparseable.
85+
"""
86+
val = self.headers.get("date")
87+
if val is None:
88+
return None
89+
try:
90+
return int(parsedate_to_datetime(val).timestamp() * 1000)
91+
except (ValueError, TypeError):
92+
return None
93+
7694
@property
7795
def is_success(self) -> bool:
7896
return 200 <= self.status < 300

0 commit comments

Comments
 (0)