Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,24 @@ jobs:
uses: runcycles/.github/.github/workflows/ci-python.yml@v1
with:
mypy-target: runcycles

recovery-conformance:
name: Durable recovery conformance
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: runcycles/cycles-protocol
ref: 594631c14710da08ad5e00125d899d642213c296
path: .cycles-protocol
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- run: python -m pip install -e ".[dev]"
- name: Run shared durable recovery scenarios
run: >-
python .cycles-protocol/scripts/run_client_recovery_conformance.py
--claim durable
--adapter python scripts/recovery_conformance_adapter.py
27 changes: 24 additions & 3 deletions .github/workflows/python-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,27 @@ permissions:
contents: read

jobs:
recovery-conformance:
name: Durable recovery conformance
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: runcycles/cycles-protocol
ref: 594631c14710da08ad5e00125d899d642213c296
path: .cycles-protocol
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- run: python -m pip install -e ".[dev]"
- name: Run shared durable recovery scenarios
run: >-
python .cycles-protocol/scripts/run_client_recovery_conformance.py
--claim durable
--adapter python scripts/recovery_conformance_adapter.py

build:
name: Build distributions
runs-on: ubuntu-latest
Expand Down Expand Up @@ -64,7 +85,7 @@ jobs:

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

publish-to-pypi:
name: Publish to PyPI
needs: build
needs: [build, recovery-conformance]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.target == 'pypi')
environment:
Expand Down Expand Up @@ -148,4 +169,4 @@ jobs:
name: ${{ github.ref_name }}
body: ${{ steps.notes.outputs.notes }}
draft: false
prerelease: ${{ contains(github.ref_name, '-') }}
prerelease: ${{ contains(github.ref_name, '-') }}
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.5.2] - 2026-07-29

### Added

- Bind all shared durable-recovery and guarantee-boundary scenarios from the
protocol repository into pull-request and release CI.
- Expose `cycles_evidence` on `CommitResponse`.

### Fixed

- Persist known actual usage before the first commit request, recover expired
commits through `/v1/events`, and accept only exact HTTP 200/201
schema-valid commit/event responses as terminal success.
- Use `v2-<sha256(exact UTF-8 reservation id)>.json` journal filenames, safely
migrate matching legacy records, and preserve collision-free cross-SDK
replay.
- Retain durable settlement records for contradictory retryable 4xx envelopes,
and report heartbeat transport failures with their same-key retry or stop
disposition.
- Quarantine unsupported or structurally invalid journal records without
aborting replay, keep serialization failures best-effort, and report exact
native test evidence to the shared conformance runner.

## [0.5.1] - 2026-07-27

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "runcycles"
version = "0.5.1"
version = "0.5.2"
description = "Python AI agent budget control — enforce LLM cost limits, tool permissions, and multi-tenant policies before agent actions execute."
readme = "README.md"
license = "Apache-2.0"
Expand Down
76 changes: 68 additions & 8 deletions runcycles/journal.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,13 @@ def _restrict_permissions(path: Path, mode: int) -> None:


def _safe_filename(reservation_id: str) -> str:
# ASCII-only, matching the TS/Java SDKs exactly: same-tenant clients in
# other languages settle records from this directory, and their discard()
# must compute the identical filename or the record replays forever.
"""Cross-SDK, collision-resistant filename for an exact reservation id."""
digest = hashlib.sha256(reservation_id.encode("utf-8")).hexdigest()
return f"v2-{digest}{_SUFFIX}"


def _legacy_filename(reservation_id: str) -> str:
"""Filename written by SDK releases before the v2 digest scheme."""
sanitized = re.sub(r"[^A-Za-z0-9_-]", "_", reservation_id)
return f"{sanitized}{_SUFFIX}"

Expand Down Expand Up @@ -127,7 +131,12 @@ def to_json(self) -> str:
@classmethod
def from_json(cls, raw: str) -> PendingCommitRecord:
data = json.loads(raw)
reservation_id = data["reservation_id"]
if not isinstance(data, dict):
raise ValueError("journal record must be a JSON object")
version = data.get("version")
if not isinstance(version, int) or isinstance(version, bool) or version != _RECORD_VERSION:
raise ValueError(f"unsupported journal version: {version!r}")
reservation_id = data.get("reservation_id")
mode = data.get("mode", "commit")
if not isinstance(reservation_id, str) or not reservation_id:
raise ValueError("journal record missing reservation_id")
Expand All @@ -137,15 +146,27 @@ def from_json(cls, raw: str) -> PendingCommitRecord:
raise ValueError("commit-mode journal record missing commit_body")
if mode == "event" and not isinstance(data.get("event_fallback_body"), dict):
raise ValueError("event-mode journal record missing event_fallback_body")
for body_key in ("commit_body", "event_fallback_body"):
if data.get(body_key) is not None and not isinstance(data[body_key], dict):
raise ValueError(f"journal record has invalid {body_key}")
if "base_url" in data and not isinstance(data["base_url"], str):
raise ValueError("journal record has invalid base_url")
recorded_at_raw = data.get("recorded_at_ms", 0)
if not isinstance(recorded_at_raw, int) or isinstance(recorded_at_raw, bool) or recorded_at_raw < 0:
raise ValueError("journal record has invalid recorded_at_ms")
not_before_raw = data.get("not_before_ms")
if not_before_raw is not None and (
not isinstance(not_before_raw, int) or isinstance(not_before_raw, bool) or not_before_raw < 0
):
raise ValueError("journal record has invalid not_before_ms")
return cls(
reservation_id=reservation_id,
base_url=data.get("base_url", ""),
mode=mode,
commit_body=data.get("commit_body"),
event_fallback_body=data.get("event_fallback_body"),
recorded_at_ms=int(data.get("recorded_at_ms", 0)),
not_before_ms=int(not_before_raw) if not_before_raw is not None else None,
recorded_at_ms=recorded_at_raw,
not_before_ms=not_before_raw,
)


Expand Down Expand Up @@ -182,14 +203,14 @@ def record(self, entry: PendingCommitRecord) -> None:
tmp.write_text(entry.to_json(), encoding="utf-8")
_restrict_permissions(tmp, 0o600)
tmp.replace(target)
except OSError:
except Exception:
try:
tmp.unlink(missing_ok=True)
except OSError:
pass
raise
logger.debug("Journaled pending commit: id=%s, path=%s", entry.reservation_id, target)
except OSError:
except Exception:
logger.warning(
"Failed to journal pending commit (continuing without durability): id=%s",
entry.reservation_id,
Expand All @@ -200,6 +221,15 @@ def discard(self, reservation_id: str) -> None:
"""Remove a journal entry after a terminal outcome. Never raises."""
try:
(self._dir / _safe_filename(reservation_id)).unlink(missing_ok=True)
legacy = self._dir / _legacy_filename(reservation_id)
if legacy.exists():
try:
entry = PendingCommitRecord.from_json(legacy.read_text(encoding="utf-8"))
if entry.reservation_id == reservation_id:
legacy.unlink(missing_ok=True)
except (OSError, ValueError, KeyError, json.JSONDecodeError):
# Never delete a colliding or malformed legacy record.
pass
except OSError:
logger.warning("Failed to discard journal entry: id=%s", reservation_id, exc_info=True)

Expand Down Expand Up @@ -235,6 +265,36 @@ def load_pending(self, base_url: str) -> list[PendingCommitRecord]:
except OSError:
pass
continue
standard_path = self._dir / _safe_filename(entry.reservation_id)
duplicate_of_standard = False
if path != standard_path:
try:
if not standard_path.exists():
path.replace(standard_path)
logger.info(
"Migrated legacy journal filename: id=%s, path=%s",
entry.reservation_id,
standard_path,
)
else:
existing = PendingCommitRecord.from_json(standard_path.read_text(encoding="utf-8"))
if existing.reservation_id == entry.reservation_id:
path.unlink(missing_ok=True)
duplicate_of_standard = True
logger.info(
"Removed duplicate legacy journal filename: id=%s, path=%s",
entry.reservation_id,
path,
)
except (OSError, ValueError, KeyError, json.JSONDecodeError):
logger.warning(
"Could not safely migrate legacy journal filename: id=%s, path=%s",
entry.reservation_id,
path,
exc_info=True,
)
if duplicate_of_standard:
continue
if entry.base_url == base_url:
entries.append(entry)
except OSError:
Expand Down
Loading