Skip to content

Commit 5e8edb4

Browse files
authored
Never release known spend after execution (#180)
* fix: never release known spend after execution * ci: retrigger security analysis
1 parent 7195b96 commit 5e8edb4

12 files changed

Lines changed: 298 additions & 68 deletions

AUDIT.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# Cycles Protocol v0.1.25 — Client (TypeScript) Audit
22

3+
**Date:** 2026-08-06 (v0.4.3 — recognized terminal commit rejection no
4+
longer releases known spend in `withCycles`; post-action failures cannot enter
5+
the guarded-function release path; actual-evaluation failure commits the
6+
estimate with an audit marker; and missing required actual configuration fails
7+
before reservation. Streaming commit rejection keeps the handle finalized so
8+
broad catch cleanup cannot release it. Lifecycle, post-action, and streaming
9+
regressions pin these paths. Final verification: 494 tests pass, 6 skip;
10+
coverage is 95.82% lines and 89.63% branches; lint, typecheck, build, package
11+
contents, and npm audit are clean.),
12+
313
**Date:** 2026-07-30 (no runtime change — the durable recovery CI job now
414
emits and uploads the profile 0.3 machine-readable evidence report even on
515
failure, and the README links its recovery-conformance badge to the public SDK

CHANGELOG.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,35 @@
11
# Changelog
22

3+
## [0.4.3] - 2026-08-06
4+
5+
### Fixed
6+
7+
- `withCycles` no longer releases a reservation after a recognized terminal
8+
commit rejection. The guarded function has already spent the resource, so
9+
returning its reserved budget would undercount known spend.
10+
- `StreamReservation.commit()` still surfaces a recognized terminal rejection,
11+
but keeps the handle finalized. A broad caller catch can no longer turn the
12+
failed settlement into a release of known spend.
13+
- `withCycles` now releases only when the guarded function itself fails.
14+
Post-action settlement/setup failures never return budget for work that
15+
already ran; a failing or invalid `actual` callback falls back to the
16+
validated estimate and records `metadata.actual_source="estimate"`.
17+
- A configuration that disables estimate fallback without providing `actual`
18+
is rejected before a reservation is created or the guarded function runs.
19+
- `StreamReservation.commit()` now replaces a non-finite, negative, fractional,
20+
or unsafe-integer actual with the validated estimate and an
21+
`actual_source="estimate"` marker instead of journaling an invalid amount.
22+
23+
### Tests and docs
24+
25+
- Regression tests pin no-release behavior for lifecycle, post-action, and
26+
streaming paths.
27+
- README settlement tables and error guidance now distinguish handler failure
28+
(release) from post-action commit rejection (never release).
29+
- The development lockfile updates `brace-expansion` to 5.0.9, clearing the
30+
current high-severity expansion DoS advisories; published runtime
31+
dependencies are unchanged.
32+
333
All notable changes to this project will be documented in this file.
434

535
The format is based on [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/).

README.md

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,12 @@ const result = await callLlm("Hello", 100);
8383
| Reservation denied | **Neither** | `BudgetExceededError`, `OverdraftLimitExceededError`, or `DebtOutstandingError` thrown; function never executes |
8484
| `dryRun: true`, any decision | **Neither** | Returns `DryRunResult` or throws; no real reservation created |
8585
| Function returns successfully | **Commit** | Actual amount charged; unused remainder auto-released |
86-
| Function throws any error | **Release** | Full reserved amount returned to budget; error re-thrown |
86+
| Guarded function throws | **Release** | Full reserved amount returned to budget; error re-thrown |
87+
| Post-action settlement setup throws | **Neither** | Error surfaces, but known spend is never released |
88+
| `actual` callback throws or returns an invalid amount | **Commit estimate** | Commit carries `metadata.actual_source="estimate"` |
8789
| Commit fails (5xx / network) | **Retry** | Exponential backoff with configurable attempts |
88-
| Commit fails (non-retryable 4xx) | **Release** | Reservation released after non-retryable client error |
89-
| Commit gets RESERVATION_EXPIRED | **Neither** | Server already reclaimed budget on TTL expiry |
90+
| Commit fails (recognized non-retryable 4xx) | **Neither** | Retry stops and the journal entry is discarded, but known spend is never released |
91+
| Commit gets RESERVATION_EXPIRED | **Event recovery** | Spend is recorded through `POST /v1/events` because the server already reclaimed the expired reservation |
9092
| Commit gets RESERVATION_FINALIZED | **Neither** | Already committed or released (idempotent replay) |
9193
| Commit gets IDEMPOTENCY_MISMATCH | **Neither** | Previous commit already processed; no release attempted |
9294
@@ -146,7 +148,8 @@ try {
146148
```
147149
148150
The handle is **once-only and race-safe**: in streaming code, multiple terminal paths can fire concurrently (onFinish, error handler, abort signal). Only the first terminal call wins:
149-
- `commit()` throws `CyclesError` if already finalized (dropping a commit silently hides bugs). If `commit()` fails due to a network or server error, `finalized` resets to `false` so you can retry — but the heartbeat is **not** restarted (restart it manually if needed)
151+
- `commit()` throws `CyclesError` if already finalized (dropping a commit silently hides bugs). Transient commit failures are durably queued and resolve normally. A recognized terminal rejection throws but leaves `finalized` true, so a broad catch cannot release known spend.
152+
- An invalid `commit(actual)` amount falls back to the reserved estimate and adds `metadata.actual_source="estimate"`; the handle never journals an invalid amount after the stream ran.
150153
- `release()` is a silent no-op if already finalized (best-effort by design)
151154
- `dispose()` stops the heartbeat only, for startup failures before streaming begins
152155
- `handle.finalized` — check whether the handle has been finalized
@@ -319,7 +322,9 @@ interface WithCyclesConfig {
319322
estimate: number | ((...args) => number); // Estimated cost (static or computed from args)
320323
321324
// Actual cost — optional (defaults to estimate if not provided)
322-
actual?: number | ((result) => number); // Actual cost (static or computed from result)
325+
actual?: number | ((result) => number); // Actual cost (static or computed from result).
326+
// Callback failure/invalid output after the action
327+
// safely falls back to the estimate.
323328
useEstimateIfActualNotProvided?: boolean; // Default: true — use estimate as actual.
324329
// When this fallback is taken the commit carries
325330
// metadata.actual_source = "estimate" so estimated
@@ -472,14 +477,14 @@ try {
472477
| `DebtOutstandingError` | Outstanding debt blocks new reservations |
473478
| `ReservationExpiredError` | Operating on an expired reservation |
474479
| `ReservationFinalizedError` | Operating on an already-committed/released reservation |
475-
| `TenantClosedError` | The owning tenant is CLOSED (HTTP 409 `TENANT_CLOSED`, runtime spec v0.1.25.13); thrown at reservation time by `withCycles` / `reserveForStream` — commit-time client errors are handled/released internally, and `StreamReservation.commit()` throws generic `CyclesError` |
480+
| `TenantClosedError` | The owning tenant is CLOSED (HTTP 409 `TENANT_CLOSED`, runtime spec v0.1.25.13); thrown at reservation time by `withCycles` / `reserveForStream` — commit-time client errors never release known spend, and `StreamReservation.commit()` throws generic `CyclesError` for recognized terminal rejection |
476481
| `CyclesTransportError` | Exported for use in your own code; never thrown by the SDK — transport failures surface as `status === -1` (see below) |
477482
478483
### Transport failures (status -1)
479484
480485
Transport failures (DNS failure, timeout, connection refused) do not surface as a distinct exception class. The SDK never throws `CyclesTransportError` itself — the class is exported for use in your own code (e.g. wrapping transport-level failures in higher-level integrations). Instead:
481486
482-
- **`withCycles` / `reserveForStream`** throw `CyclesProtocolError` with `status === -1` and `errorCode` `undefined` for **reservation-time** transport failures. Commit-time failures differ: `withCycles` retries the commit in the background (fire-and-forget), while `StreamReservation.commit()` throws and resets `finalized` so you can retry or `release()`.
487+
- **`withCycles` / `reserveForStream`** throw `CyclesProtocolError` with `status === -1` and `errorCode` `undefined` for **reservation-time** transport failures. Commit-time transient failures are journaled for recovery. `StreamReservation.commit()` throws only for recognized terminal rejection and remains finalized so the caller cannot release known spend.
483488
- **Programmatic `CyclesClient` calls** never throw on transport failure — they return a `CyclesResponse` with `isTransportError` set and `status` of `-1` (see below).
484489
485490
### With `CyclesClient` (programmatic)
@@ -725,7 +730,7 @@ A commit records spend that has already happened, so the SDK never lets one exis
725730
- **Rate limiting:** 429 / `LIMIT_EXCEEDED` is transient everywhere — a rate-limited first commit is scheduled for retry (never released, which would return budget for spend that already happened), and the next attempt waits at least the server's `Retry-After`. The floor is persisted as an absolute timestamp, so a restart mid-wait still honors it.
726731
- **Authentication failures:** 401/403 on any commit attempt journals the spend (never releases it) and stops the current run's attempts, so spend recorded during a key misconfiguration or rotation window replays once credentials are fixed.
727732
- **Expired reservations:** a commit answered `RESERVATION_EXPIRED` (or a bodyless HTTP 410) — where the server has already returned the reserved budget to the pool — is recovered via `POST /v1/events` (the protocol's post-hoc direct-debit endpoint), reusing the commit's idempotency key and tagging `metadata.recovered_reservation_id` for reconciliation.
728-
- **Genuine rejections** — 4xx responses carrying a **recognized** protocol error code (e.g. `UNIT_MISMATCH`) — stop retries and discard the journal entry retrying cannot fix a malformed commit. Codeless, mangled, or unknown (forward-compat) error codes are *not* treated as rejections: the spend record is journaled and retained for replay instead of being released or discarded.
733+
- **Genuine rejections** — 4xx responses carrying a **recognized** protocol error code (e.g. `UNIT_MISMATCH`) — stop retries and discard the journal entry because retrying cannot fix a malformed commit. They never release the reservation after the guarded action has spent the resource. Codeless, mangled, or unknown (forward-compat) error codes are *not* treated as rejections: the spend record is journaled and retained for replay instead of being released or discarded.
729734
- **Delay clamp:** any server-requested wait (`Retry-After`, persisted floors) is honored for at most 1 hour.
730735
- Applies to `withCycles` **and** the streaming adapter's `handle.commit()`; the programmatic client (`client.commitReservation`) stays manual.
731736
- Retry timers are ref'd, so a naturally-draining Node process waits for in-flight retries; `process.exit()`, crashes, and signals are covered by journal replay on the next run. Set `journalEnabled: false` (or `CYCLES_JOURNAL_ENABLED=false`) to opt out.

package-lock.json

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "runcycles",
3-
"version": "0.4.2",
3+
"version": "0.4.3",
44
"description": "TypeScript AI agent runtime control — enforce LLM cost limits, action permissions, and audit trails for agents before execution.",
55
"license": "Apache-2.0",
66
"author": "runcycles",

src/lifecycle.ts

Lines changed: 53 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,22 @@ export class AsyncCyclesLifecycle {
600600
): Promise<T> {
601601
const estimate = evaluateAmount(cfg.estimate, args);
602602

603+
// Configuration errors must fail before a reservation exists. Otherwise a
604+
// successful guarded action could be followed by a missing-actual error and
605+
// the generic failure path would incorrectly return budget for known spend.
606+
if (
607+
!cfg.dryRun &&
608+
cfg.actual === undefined &&
609+
cfg.useEstimateIfActualNotProvided === false
610+
) {
611+
throw new Error(
612+
"actual expression is required when useEstimateIfActualNotProvided is false",
613+
);
614+
}
615+
if (!cfg.dryRun && typeof cfg.actual === "number") {
616+
validateNonNegative(cfg.actual, "actual");
617+
}
618+
603619
const createBody = buildReservationBody(
604620
cfg,
605621
estimate,
@@ -677,18 +693,36 @@ export class AsyncCyclesLifecycle {
677693
ctx,
678694
);
679695

696+
let guardedFunctionCompleted = false;
680697
try {
681698
const result = await runWithContext(ctx, () => fn(...args));
699+
guardedFunctionCompleted = true;
682700
const methodElapsed = Math.round(performance.now() - resT2);
683701

684702
// Resolve actual
685703
const useEstimateFallback = cfg.useEstimateIfActualNotProvided !== false;
686-
const { amount: actualAmount, usedEstimateFallback } = evaluateActual(
687-
cfg.actual,
688-
result,
689-
estimate,
690-
useEstimateFallback,
691-
);
704+
let actualAmount: number;
705+
let usedEstimateFallback: boolean;
706+
try {
707+
const resolved = evaluateActual(
708+
cfg.actual,
709+
result,
710+
estimate,
711+
useEstimateFallback,
712+
);
713+
validateNonNegative(resolved.amount, "actual");
714+
({ amount: actualAmount, usedEstimateFallback } = resolved);
715+
} catch (err) {
716+
// The action already ran. Preserve the spend by committing the validated
717+
// estimate instead of releasing or losing the reservation because a
718+
// user-supplied post-action accounting callback failed.
719+
actualAmount = estimate;
720+
usedEstimateFallback = true;
721+
console.warn(
722+
`[runcycles] Actual evaluation failed after the guarded action completed; committing the estimate instead: ${reservationId}`,
723+
err,
724+
);
725+
}
692726

693727
// Build commit
694728
let metrics = ctx.metrics;
@@ -727,7 +761,14 @@ export class AsyncCyclesLifecycle {
727761

728762
return result;
729763
} catch (err) {
730-
await this._handleRelease(reservationId, "guarded_method_failed");
764+
if (!guardedFunctionCompleted) {
765+
await this._handleRelease(reservationId, "guarded_method_failed");
766+
} else {
767+
console.error(
768+
`[runcycles] Post-action settlement failed; not releasing known spend: ${reservationId}`,
769+
err,
770+
);
771+
}
731772
throw err;
732773
} finally {
733774
if (heartbeatRef) {
@@ -827,12 +868,12 @@ export class AsyncCyclesLifecycle {
827868
parsedErrorCode !== ErrorCode.UNKNOWN &&
828869
!isRetryableErrorCode(parsedErrorCode)
829870
) {
830-
// Recognized protocol code — a genuine rejection the retry
831-
// engine cannot fix. Releasing returns the reserved budget.
871+
// Recognized protocol code — a genuine rejection the retry engine
872+
// cannot fix. The guarded function has already spent the resource,
873+
// so releasing here would return budget for known spend.
832874
this._retryEngine.discardPending(reservationId);
833-
await this._handleRelease(
834-
reservationId,
835-
`commit_rejected_${errorCode}`,
875+
console.error(
876+
`[runcycles] Commit was rejected after spend was recorded locally (error=${String(errorCode)}); not releasing known spend: ${reservationId}`,
836877
);
837878
return;
838879
}

src/streaming.ts

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,9 @@ export interface StreamReservation {
9999
* (codeless or unrecognized error codes) are handled internally —
100100
* journaled and retried in the background (with a `POST /v1/events`
101101
* fallback once the reservation has expired) — and resolve normally.
102-
* Only genuine rejections carrying a recognized protocol error code
103-
* (e.g. UNIT_MISMATCH) reset `finalized` and throw.
102+
* Genuine rejections carrying a recognized protocol error code (e.g.
103+
* UNIT_MISMATCH) discard the unrecoverable journal entry and throw, but
104+
* remain finalized so a broad catch cannot release known spend.
104105
*/
105106
commit(
106107
actual: number,
@@ -732,17 +733,32 @@ export async function reserveForStream(
732733
if (finalized) {
733734
throw new CyclesError("StreamReservation already finalized");
734735
}
736+
let settledActual = actual;
737+
let settledMetadata = metadata;
738+
try {
739+
validateNonNegative(actual, "actual");
740+
} catch (err) {
741+
// The stream has already consumed resources. An invalid caller-side
742+
// measurement must not strand or release the reservation; settle the
743+
// validated estimate and mark the evidence instead.
744+
settledActual = estimate;
745+
settledMetadata = { ...(metadata ?? {}), actual_source: "estimate" };
746+
console.warn(
747+
`[runcycles] Stream actual is invalid; committing the estimate instead: ${reservationId}`,
748+
err,
749+
);
750+
}
735751
finalized = true;
736752
stopHeartbeat();
737753
const commitBody: Record<string, unknown> = {
738754
idempotency_key: randomUUID(),
739-
actual: { unit, amount: actual },
755+
actual: { unit, amount: settledActual },
740756
};
741757
if (metrics && !isMetricsEmpty(metrics)) {
742758
commitBody.metrics = metricsToWire(metrics);
743759
}
744-
if (metadata) {
745-
commitBody.metadata = metadata;
760+
if (settledMetadata) {
761+
commitBody.metadata = settledMetadata;
746762
}
747763
const eventFallback = buildEventFallbackBody(
748764
reservationId,
@@ -830,14 +846,10 @@ export async function reserveForStream(
830846
!isRetryableErrorCode(parsedErrorCode)
831847
) {
832848
// Genuine rejection (e.g. UNIT_MISMATCH — a recognized protocol
833-
// code): reset finalized so the caller can correct and retry
834-
// commit or fall back to release. The heartbeat is NOT restarted
835-
// to avoid spawning duplicate heartbeat chains (an old in-flight
836-
// extend's .finally→tick could race with a new startHeartbeat
837-
// call). The reservation's remaining TTL should give the caller
838-
// enough time to retry or release.
849+
// code): the retry engine cannot fix the malformed commit. The spend
850+
// already happened, so keep the handle finalized; a broad caller
851+
// catch must not be able to release the reserved budget.
839852
retryEngine.discardPending(reservationId);
840-
finalized = false;
841853
throw new CyclesError(
842854
`Commit failed with status ${response.status}: ${response.errorMessage ?? "unknown error"}`,
843855
);

src/validation.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ export function validateSubject(subject: Subject | undefined): void {
2020
}
2121

2222
export function validateNonNegative(value: number, name: string): void {
23-
if (value < 0) {
24-
throw new Error(`${name} must be non-negative, got ${value}`);
23+
if (!Number.isSafeInteger(value) || value < 0) {
24+
throw new Error(`${name} must be a finite, non-negative safe integer, got ${value}`);
2525
}
2626
}
2727

0 commit comments

Comments
 (0)