Skip to content

Commit a9d41b4

Browse files
committed
fix(recovery): guarantee durable settlement
1 parent 6ec1f8e commit a9d41b4

12 files changed

Lines changed: 575 additions & 47 deletions

File tree

.github/workflows/ci.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,24 @@ jobs:
1414
uses: runcycles/.github/.github/workflows/ci-python.yml@v1
1515
with:
1616
mypy-target: runcycles
17+
18+
recovery-conformance:
19+
name: Durable recovery conformance
20+
runs-on: ubuntu-latest
21+
steps:
22+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
23+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
24+
with:
25+
repository: runcycles/cycles-protocol
26+
ref: 8a22803316d91b85e444f4fb40ff2b8426f11773
27+
path: .cycles-protocol
28+
persist-credentials: false
29+
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
30+
with:
31+
python-version: "3.12"
32+
- run: python -m pip install -e ".[dev]"
33+
- name: Run shared durable recovery scenarios
34+
run: >-
35+
python .cycles-protocol/scripts/run_client_recovery_conformance.py
36+
--claim durable
37+
--adapter python scripts/recovery_conformance_adapter.py

.github/workflows/python-publish.yml

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,27 @@ permissions:
2020
contents: read
2121

2222
jobs:
23+
recovery-conformance:
24+
name: Durable recovery conformance
25+
runs-on: ubuntu-latest
26+
steps:
27+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
28+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
29+
with:
30+
repository: runcycles/cycles-protocol
31+
ref: 8a22803316d91b85e444f4fb40ff2b8426f11773
32+
path: .cycles-protocol
33+
persist-credentials: false
34+
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
35+
with:
36+
python-version: "3.12"
37+
- run: python -m pip install -e ".[dev]"
38+
- name: Run shared durable recovery scenarios
39+
run: >-
40+
python .cycles-protocol/scripts/run_client_recovery_conformance.py
41+
--claim durable
42+
--adapter python scripts/recovery_conformance_adapter.py
43+
2344
build:
2445
name: Build distributions
2546
runs-on: ubuntu-latest
@@ -64,7 +85,7 @@ jobs:
6485

6586
publish-to-testpypi:
6687
name: Publish to TestPyPI
67-
needs: build
88+
needs: [build, recovery-conformance]
6889
runs-on: ubuntu-latest
6990
if: github.event_name == 'workflow_dispatch' && inputs.target == 'testpypi'
7091
environment:
@@ -88,7 +109,7 @@ jobs:
88109

89110
publish-to-pypi:
90111
name: Publish to PyPI
91-
needs: build
112+
needs: [build, recovery-conformance]
92113
runs-on: ubuntu-latest
93114
if: startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.target == 'pypi')
94115
environment:
@@ -148,4 +169,4 @@ jobs:
148169
name: ${{ github.ref_name }}
149170
body: ${{ steps.notes.outputs.notes }}
150171
draft: false
151-
prerelease: ${{ contains(github.ref_name, '-') }}
172+
prerelease: ${{ contains(github.ref_name, '-') }}

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.5.2] - 2026-07-29
9+
10+
### Added
11+
12+
- Bind all shared durable-recovery and guarantee-boundary scenarios from the
13+
protocol repository into pull-request and release CI.
14+
- Expose `cycles_evidence` on `CommitResponse`.
15+
16+
### Fixed
17+
18+
- Persist known actual usage before the first commit request, recover expired
19+
commits through `/v1/events`, and accept only exact HTTP 200/201
20+
schema-valid commit/event responses as terminal success.
21+
- Use `v2-<sha256(exact UTF-8 reservation id)>.json` journal filenames, safely
22+
migrate matching legacy records, and preserve collision-free cross-SDK
23+
replay.
24+
- Retain durable settlement records for contradictory retryable 4xx envelopes,
25+
and report heartbeat transport failures with their same-key retry or stop
26+
disposition.
27+
828
## [0.5.1] - 2026-07-27
929

1030
### Fixed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "runcycles"
7-
version = "0.5.1"
7+
version = "0.5.2"
88
description = "Python AI agent budget control — enforce LLM cost limits, tool permissions, and multi-tenant policies before agent actions execute."
99
readme = "README.md"
1010
license = "Apache-2.0"

runcycles/journal.py

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,13 @@ def _restrict_permissions(path: Path, mode: int) -> None:
8989

9090

9191
def _safe_filename(reservation_id: str) -> str:
92-
# ASCII-only, matching the TS/Java SDKs exactly: same-tenant clients in
93-
# other languages settle records from this directory, and their discard()
94-
# must compute the identical filename or the record replays forever.
92+
"""Cross-SDK, collision-resistant filename for an exact reservation id."""
93+
digest = hashlib.sha256(reservation_id.encode("utf-8")).hexdigest()
94+
return f"v2-{digest}{_SUFFIX}"
95+
96+
97+
def _legacy_filename(reservation_id: str) -> str:
98+
"""Filename written by SDK releases before the v2 digest scheme."""
9599
sanitized = re.sub(r"[^A-Za-z0-9_-]", "_", reservation_id)
96100
return f"{sanitized}{_SUFFIX}"
97101

@@ -200,6 +204,15 @@ def discard(self, reservation_id: str) -> None:
200204
"""Remove a journal entry after a terminal outcome. Never raises."""
201205
try:
202206
(self._dir / _safe_filename(reservation_id)).unlink(missing_ok=True)
207+
legacy = self._dir / _legacy_filename(reservation_id)
208+
if legacy.exists():
209+
try:
210+
entry = PendingCommitRecord.from_json(legacy.read_text(encoding="utf-8"))
211+
if entry.reservation_id == reservation_id:
212+
legacy.unlink(missing_ok=True)
213+
except (OSError, ValueError, KeyError, json.JSONDecodeError):
214+
# Never delete a colliding or malformed legacy record.
215+
pass
203216
except OSError:
204217
logger.warning("Failed to discard journal entry: id=%s", reservation_id, exc_info=True)
205218

@@ -235,6 +248,38 @@ def load_pending(self, base_url: str) -> list[PendingCommitRecord]:
235248
except OSError:
236249
pass
237250
continue
251+
standard_path = self._dir / _safe_filename(entry.reservation_id)
252+
duplicate_of_standard = False
253+
if path != standard_path:
254+
try:
255+
if not standard_path.exists():
256+
path.replace(standard_path)
257+
logger.info(
258+
"Migrated legacy journal filename: id=%s, path=%s",
259+
entry.reservation_id,
260+
standard_path,
261+
)
262+
else:
263+
existing = PendingCommitRecord.from_json(
264+
standard_path.read_text(encoding="utf-8")
265+
)
266+
if existing.reservation_id == entry.reservation_id:
267+
path.unlink(missing_ok=True)
268+
duplicate_of_standard = True
269+
logger.info(
270+
"Removed duplicate legacy journal filename: id=%s, path=%s",
271+
entry.reservation_id,
272+
path,
273+
)
274+
except (OSError, ValueError, KeyError, json.JSONDecodeError):
275+
logger.warning(
276+
"Could not safely migrate legacy journal filename: id=%s, path=%s",
277+
entry.reservation_id,
278+
path,
279+
exc_info=True,
280+
)
281+
if duplicate_of_standard:
282+
continue
238283
if entry.base_url == base_url:
239284
entries.append(entry)
240285
except OSError:

runcycles/lifecycle.py

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
CommitRetryEngine,
4747
_extract_error_code,
4848
_is_recognized_rejection,
49+
_is_schema_valid_commit_success,
4950
)
5051

5152
logger = logging.getLogger(__name__)
@@ -763,11 +764,23 @@ def _handle_commit(
763764
commit_body: dict[str, Any],
764765
event_fallback_body: dict[str, Any],
765766
) -> None:
767+
self._retry_engine.persist_pending(
768+
reservation_id, commit_body, event_fallback_body
769+
)
766770
try:
767771
logger.debug("Committing: id=%s", reservation_id)
768772
response = self._client.commit_reservation(reservation_id, commit_body)
769-
if response.is_success:
773+
if _is_schema_valid_commit_success(response):
774+
self._retry_engine.discard_pending(reservation_id)
770775
logger.info("Commit successful: id=%s", reservation_id)
776+
elif response.is_success:
777+
logger.warning(
778+
"Commit returned ambiguous protocol-invalid 2xx; scheduling same-key retry: "
779+
"id=%s, status=%d",
780+
reservation_id,
781+
response.status,
782+
)
783+
self._retry_engine.schedule(reservation_id, commit_body, event_fallback_body)
771784
elif response.is_transport_error or response.is_server_error:
772785
logger.warning("Commit failed (retryable): id=%s, status=%d", reservation_id, response.status)
773786
self._retry_engine.schedule(reservation_id, commit_body, event_fallback_body)
@@ -804,10 +817,13 @@ def _handle_commit(
804817
)
805818
self._retry_engine.schedule_event(reservation_id, event_fallback_body)
806819
elif error_code == "RESERVATION_FINALIZED":
820+
self._retry_engine.discard_pending(reservation_id)
807821
logger.warning("Reservation already finalized: id=%s", reservation_id)
808822
elif error_code == "IDEMPOTENCY_MISMATCH":
823+
self._retry_engine.discard_pending(reservation_id)
809824
logger.warning("Commit idempotency mismatch (not releasing): id=%s", reservation_id)
810825
elif response.is_client_error and _is_recognized_rejection(error_code):
826+
self._retry_engine.discard_pending(reservation_id)
811827
self._handle_release(reservation_id, f"commit_rejected_{error_code}")
812828
elif response.is_client_error:
813829
# Codeless or forward-compat-unknown 4xx: neither release
@@ -820,7 +836,12 @@ def _handle_commit(
820836
)
821837
self._retry_engine.schedule(reservation_id, commit_body, event_fallback_body)
822838
else:
823-
logger.warning("Unrecognized commit response: id=%s, response=%s", reservation_id, response)
839+
logger.warning(
840+
"Unrecognized commit response; scheduling same-key retry: id=%s, response=%s",
841+
reservation_id,
842+
response,
843+
)
844+
self._retry_engine.schedule(reservation_id, commit_body, event_fallback_body)
824845
except Exception:
825846
logger.exception("Failed to commit: id=%s", reservation_id)
826847
self._retry_engine.schedule(reservation_id, commit_body, event_fallback_body)
@@ -1047,16 +1068,24 @@ def heartbeat_loop() -> None:
10471068
return
10481069
delay_ms = nxt
10491070
except Exception:
1050-
logger.warning("Heartbeat extend error: id=%s", reservation_id, exc_info=True)
10511071
if authoritative:
10521072
nxt = sched.on_transient_failure(_now_mono_ms())
10531073
if nxt is None:
10541074
logger.warning(
1055-
"Heartbeat stopping: no safe recovery window remains: id=%s",
1075+
"Heartbeat extend transport error; stopping because no safe recovery "
1076+
"window remains: id=%s",
10561077
reservation_id,
1078+
exc_info=True,
10571079
)
10581080
return
10591081
delay_ms = nxt
1082+
logger.warning(
1083+
"Heartbeat extend transport error; retrying with the same idempotency key "
1084+
"in %.0fms: id=%s",
1085+
delay_ms,
1086+
reservation_id,
1087+
exc_info=True,
1088+
)
10601089

10611090
t = threading.Thread(target=heartbeat_loop, daemon=True, name=f"cycles-heartbeat-{reservation_id[:12]}")
10621091
t.start()
@@ -1189,10 +1218,22 @@ async def _handle_commit(
11891218
commit_body: dict[str, Any],
11901219
event_fallback_body: dict[str, Any],
11911220
) -> None:
1221+
self._retry_engine.persist_pending(
1222+
reservation_id, commit_body, event_fallback_body
1223+
)
11921224
try:
11931225
response = await self._client.commit_reservation(reservation_id, commit_body)
1194-
if response.is_success:
1226+
if _is_schema_valid_commit_success(response):
1227+
self._retry_engine.discard_pending(reservation_id)
11951228
logger.info("Commit successful: id=%s", reservation_id)
1229+
elif response.is_success:
1230+
logger.warning(
1231+
"Commit returned ambiguous protocol-invalid 2xx; scheduling same-key retry: "
1232+
"id=%s, status=%d",
1233+
reservation_id,
1234+
response.status,
1235+
)
1236+
self._retry_engine.schedule(reservation_id, commit_body, event_fallback_body)
11961237
elif response.is_transport_error or response.is_server_error:
11971238
self._retry_engine.schedule(reservation_id, commit_body, event_fallback_body)
11981239
else:
@@ -1228,10 +1269,13 @@ async def _handle_commit(
12281269
)
12291270
self._retry_engine.schedule_event(reservation_id, event_fallback_body)
12301271
elif error_code == "RESERVATION_FINALIZED":
1272+
self._retry_engine.discard_pending(reservation_id)
12311273
logger.warning("Reservation already finalized: id=%s", reservation_id)
12321274
elif error_code == "IDEMPOTENCY_MISMATCH":
1275+
self._retry_engine.discard_pending(reservation_id)
12331276
logger.warning("Commit idempotency mismatch (not releasing): id=%s", reservation_id)
12341277
elif response.is_client_error and _is_recognized_rejection(error_code):
1278+
self._retry_engine.discard_pending(reservation_id)
12351279
await self._handle_release(reservation_id, f"commit_rejected_{error_code}")
12361280
elif response.is_client_error:
12371281
# Codeless or forward-compat-unknown 4xx: neither release
@@ -1244,7 +1288,12 @@ async def _handle_commit(
12441288
)
12451289
self._retry_engine.schedule(reservation_id, commit_body, event_fallback_body)
12461290
else:
1247-
logger.warning("Unrecognized commit response: id=%s, response=%s", reservation_id, response)
1291+
logger.warning(
1292+
"Unrecognized commit response; scheduling same-key retry: id=%s, response=%s",
1293+
reservation_id,
1294+
response,
1295+
)
1296+
self._retry_engine.schedule(reservation_id, commit_body, event_fallback_body)
12481297
except Exception:
12491298
logger.exception("Failed to commit: id=%s", reservation_id)
12501299
self._retry_engine.schedule(reservation_id, commit_body, event_fallback_body)
@@ -1457,16 +1506,24 @@ async def heartbeat_loop() -> None:
14571506
return
14581507
delay_ms = nxt
14591508
except Exception:
1460-
logger.warning("Heartbeat extend error: id=%s", reservation_id, exc_info=True)
14611509
if authoritative:
14621510
nxt = sched.on_transient_failure(_now_mono_ms())
14631511
if nxt is None:
14641512
logger.warning(
1465-
"Heartbeat stopping: no safe recovery window remains: id=%s",
1513+
"Heartbeat extend transport error; stopping because no safe "
1514+
"recovery window remains: id=%s",
14661515
reservation_id,
1516+
exc_info=True,
14671517
)
14681518
return
14691519
delay_ms = nxt
1520+
logger.warning(
1521+
"Heartbeat extend transport error; retrying with the same idempotency "
1522+
"key in %.0fms: id=%s",
1523+
delay_ms,
1524+
reservation_id,
1525+
exc_info=True,
1526+
)
14701527
except asyncio.CancelledError:
14711528
return
14721529

runcycles/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,7 @@ class CommitResponse(BaseModel):
309309
charged: Amount
310310
released: Amount | None = None
311311
balances: list[Balance] | None = None
312+
cycles_evidence: CyclesEvidenceRef | None = None
312313

313314

314315
class ReleaseResponse(BaseModel):

0 commit comments

Comments
 (0)