Metadata-Version: 2.4
Name: cascade-coordinate
Version: 0.2.0
Summary: Coordinate fleets of AI agents, workers and machines: claim a job, get a grant or a hold, keep a signed receipt of every decision. Client for the Cascade coordination API.
Author-email: Cascade Dynamics <info@cascadedynamics.ai>
Maintainer-email: Cascade Dynamics <info@cascadedynamics.ai>
License-Expression: Apache-2.0
Project-URL: Homepage, https://cascadedynamics.ai
Project-URL: Documentation, https://cascadedynamics.ai/docs.html
Project-URL: Agent guide, https://cascadedynamics.ai/SKILL.md
Project-URL: Verify, https://cascadedynamics.ai/verify.html
Keywords: coordination,multi-agent,agents,ai-agents,fleet,robotics,mutex,lease,lock,audit,verifiable,signed,receipts
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: System :: Distributed Computing
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
License-File: NOTICE
Dynamic: license-file

# cascade-coordinate

**Coordinate fleets of AI agents, workers and machines — Python client for the Cascade API.**

Before an agent acts on something shared, it asks Cascade for the job. Cascade
answers **yes** (only this agent has it now) or **wait** (another agent has it).
Every answer is signed into a tamper-evident record you can check offline. No
model is involved in the decision, so it costs no LLM tokens.

```bash
pip install cascade-coordinate
```

> The distribution is `cascade-coordinate`; the import is `cascade`. There is
> an unrelated package called `cascade-sdk` on PyPI, published by someone else.

## Quickstart

1. Get a free API key at <https://cascadedynamics.ai/auth.html#signup1> and
   **verify your email** (until then claims answer `403 email_verification_required`).
2. Put the key in the environment — never in code:

   ```bash
   export CASCADE_API_KEY=csc_...
   ```

3. Wrap the work that must not run twice at once:

```python
from cascade import Layer

layer = Layer()                       # reads CASCADE_API_KEY

def send_invoice(grant):
    # grant.entry_hash is the receipt: store it next to what you did
    return billing.send(991, idempotency_key="tool/send-invoice-991")

result = layer.with_job("tool/send-invoice-991", "worker-a", 30_000, send_invoice)
if result is None:
    print("another worker has it")    # a hold: do something else
```

`with_job` claims the cell, runs your function only if this agent got it,
renews the claim in the background while the work runs, and releases it
afterwards — even if the function raises.

The same thing as a `with` block:

```python
with layer.hold("deploy/payments-api/prod", agent="worker-a", ttl_ms=30_000) as grant:
    if grant.ok:
        deploy()
    else:
        print("held behind", grant.behind)
```

Or by hand:

```python
grant = layer.claim("tickets/4812", agent="worker-a", ttl_ms=30_000)
if grant.ok:
    try:
        handle(4812)
    finally:
        layer.release(grant)
else:
    grant.wait(timeout_ms=10_000)     # waiting is free; returns still-held on timeout
```

## Wait without polling

Pass `wait_ms` and the server holds the request open until the cell frees,
then grants it — no polling loop, no polling latency. Waiting is free: the one
HOLD on record is replayed, with no new receipt and no charge.

```python
grant = layer.claim("bay/4", agent="worker-a", wait_ms=5_000)   # answers the moment it frees
grant.waited_ms                                                 # how long the server held it
```

One request waits at most `MAX_WAIT_MS` (10 s, the server's cap); longer waits,
and `grant.wait(timeout_ms)`, run as a loop of those long-polls. Against a
server without long-poll, `wait()` falls back to polite polling.

## Several cells at once — all or nothing

```python
g = layer.claim_many(["robot/arm-2", "bay/4", "tool/torch-1"], agent="cell-ctl", ttl_ms=30_000)
if g.ok:
    g.fences                       # {cell: fence} — each cell has its own entry and fence
    g.grants                       # [CellGrant(cell, entry, fence, entry_hash, signed), ...]
else:
    print("blocked on", g.cell, "held by", g.behind)               # nothing was granted
```

Up to 16 cells (`MAX_CLAIM_CELLS`). Either every cell is granted or none is,
so an agent never sits on half of what it needs while waiting for the rest.
`wait()`, `renew()`, `keep_alive()` and `release()` act on all the cells; a
grant costs one decision per cell, a hold costs one. `with_job` and `hold` take
a list of cells too.

## Fencing tokens

Every grant carries `grant.fence`: the same for every renewal of one holding
period, larger for every new one, and it keeps growing across API restarts. A
worker that stalls past its TTL and wakes up still believing it holds the cell
writes with an *old* fence. Have the store you write to reject it:

```python
from cascade import FenceGuard

guard = FenceGuard()                       # in front of your store
if guard.accept(grant):                    # or guard.accept("bay/4", fence)
    store.write(...)
else:
    ...                                    # a newer holder exists: drop this write
```

`accept` remembers the highest fence per cell and accepts equal ones
(renewals). `check()` raises `StaleFenceError` instead. `snapshot()` /
`FenceGuard(initial)` carry it across restarts of your store. If the store is
a database, the same rule is one conditional update: write only where the
stored fence is not higher than yours.

## Signed outcomes

A release can say what happened, signed into the same record as the grant:

```python
from cascade import digest, outcome

layer.release(grant, outcome="done")
layer.release(grant, outcome={"status": "done", "result": report_bytes})   # hashed here
layer.release(grant, outcome=outcome("failed"))
grant.released_entry_hash                  # the RELEASE receipt
```

`status` is 1–32 characters of `A-Z a-z 0-9 _ . : -`. `result` (bytes or str)
never leaves your machine — only its SHA-256 does, so anyone holding the
result can later show it is the one that was reported. `digest(data)` gives
you the same hash. A malformed outcome raises `ValueError` before anything is
sent, so the job stays yours.

`with_job` can do it for you:

```python
layer.with_job("reports/q3", "worker-a", 30_000, build_report, outcome=True)
# success: {"status": "done", "digest": sha256(result)} (digest when result is str/bytes)
# raised:  {"status": "failed"}
```

`outcome=` also takes a status, an outcome dict, or `f(result)`; call
`grant.report("partial", data)` inside the work to override it.

## Restarts

Holds survive an API restart: they are rebuilt from the signed record. The
client retries network errors for `retry_window` seconds, so a claim, renewal,
long-poll or release in flight during a restart lands on the same state when
the API is back. Size `retry_window` for the outages you want to ride through.

## The whole surface

| Call | What it does |
|---|---|
| `layer.claim(cell, agent, ttl_ms=30000, wait_ms=0)` | Ask for the job. Returns a `Grant` — granted or held, never an exception for a hold. `wait_ms` long-polls for a held cell. |
| `layer.claim_many(cells, agent, ttl_ms=30000, wait_ms=0)` | All-or-nothing over up to 16 cells. Returns a `MultiGrant`. |
| `grant.ok` / `grant.hold` / `grant.behind` | Yours / someone else's / who has it. |
| `grant.entry_hash`, `grant.entry` | The receipt (`entry` is `None` on a hold). |
| `grant.fence`, `grant.fences` | Fencing token(s) for downstream writes. |
| `grant.deduped`, `grant.waited_ms` | A replayed (free) hold; how long the server long-polled. |
| `grant.wait(timeout_ms, poll_ms)` | Long-poll until free. Bounded. |
| `grant.renew(ttl_ms=None)` | Claim again to extend the TTL (same fence). Returns `False` and sets `grant.lost` if another agent took it after a lapse. |
| `grant.keep_alive(every_ms=None, on_lost=None)` | Renew in a background thread (default every third of the TTL) until released. |
| `grant.report(status, result=None)` | Set the outcome the release will sign. |
| `layer.release(grant, outcome=None)` / `layer.release(cell, agent=..., outcome=None)` | Hand it back, optionally with a signed outcome. Returns whether the server released it; the receipt is on `grant.released_entry_hash` / `layer.last_release`. |
| `layer.with_job(cell_or_cells, agent, ttl_ms, work, wait_ms=0, renew=True, outcome=None)` | Claim → `work(grant)` → release. `None` when held. |
| `layer.hold(cell_or_cells, agent, ttl_ms, wait_ms=0, renew=True)` | Claim for a `with` block; released on exit. |
| `FenceGuard()`, `digest(data)`, `outcome(status, result=None)` | Fence checks for your store; SHA-256 helper; outcome builder. |
| `layer.usage()` | `GET /v1/usage` — signed decisions used against your plan. |
| `layer.runs()` / `layer.run()` | `GET /v1/runs` / `GET /v1/runs/current`. |
| `layer.bundle(run_id=None)` | `GET /v1/runs/current/bundle` — the signed record plus its public key. |

**TTLs: keep them short and renew.** `ttl_ms` is how long the job stays yours
if your process dies without releasing. Seconds to minutes, renewed while the
work runs, beats hours: a crashed worker then frees the cell quickly. Each
renewal is one decision on your meter; waiting is not.

**A claim is not an idempotency key.** It stops two agents acting at the same
time; it does not stop a late finisher whose TTL already lapsed, or a retry
after a crash. For effects that must happen exactly once, also pass a stable
idempotency key (the cell name works) to the downstream system.

## Errors are typed

```python
from cascade import (AuthError, BadRequestError, CascadeError, QuotaExceededError,
                     RateLimitedError, SdkAccessError, VerifyRequiredError)

try:
    grant = layer.claim("c", agent="a")
except BadRequestError:        # 400 — malformed claim; e.code == "bad_outcome" for a release outcome
    ...
except AuthError:              # 401 — key missing, wrong or revoked
    ...
except QuotaExceededError as e:  # 402 quota_exceeded — nothing charged; e.upgrade_url / e.contact
    ...
except VerifyRequiredError:    # 403 email_verification_required — click the link in the signup email
    ...
except SdkAccessError as e:    # 403 sdk_not_included / sdk_sandbox_only — the REST API still works
    ...
except RateLimitedError:       # 429 that outlasted the retry window
    ...
except CascadeError as e:      # anything else: e.status (0 = network), e.code, e.body
    ...
```

## Retries

`429`, `502`, `503` (including `writer_handoff` during a deploy) and `504`, and
network errors, are retried with backoff for `retry_window` seconds (default
25; `Layer(retry_window=0)` disables). `400`, `401`, `402` and `403` are
decisions, not blips, and are never retried.

## Configuration

| | |
|---|---|
| `CASCADE_API_KEY` | your key. Keep it server-side; never ship it to a browser or app. |
| `CASCADE_API` (or `CASCADE_BASE_URL`) | API base; default `https://api.cascadedynamics.ai/v1` |
| `Layer(key=, base_url=, agent=, timeout=30, retry_window=25)` | the same, in code; `agent` is a default for every call |

Python 3.9+. **Standard library only** — no dependencies.

## What this client sends about you

One header, `User-Agent: cascade-sdk-python/0.2.0 (python/3.12.6; Windows)` —
the SDK name and version, the Python version and the OS family — repeated on
`X-Cascade-Client`. No hostname, username, environment, file or project names,
and no request the client makes on its own. A custom User-Agent also matters
because some default HTTP agents are blocked. If the server says your version
is old, the notice is on `layer.last_warning`; it never blocks a call.

## Checking the receipts

The client holds no signing key; its only cryptography is the optional
SHA-256 of your own result for an outcome. Save
`layer.bundle()` as JSON and drop it on
[cascadedynamics.ai/verify.html](https://cascadedynamics.ai/verify.html): the
page recomputes the hash chain and every Ed25519 signature in your browser and
uploads nothing.

## Development

```bash
python -m unittest discover -s tests -t .    # from sdk/python
python tests/live_check.py                   # against a LOCAL API (CASCADE_API, CASCADE_API_KEY)
python tests/restart_check.py                # spawns, kills and restarts a local API (CASCADE_SERVER_MJS)
python -m build                              # sdist + wheel into dist/
```

## License

Apache-2.0 — see [LICENSE](LICENSE) and [NOTICE](NOTICE), which covers use of the Cascade Dynamics name. Help: info@cascadedynamics.ai
