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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ surface is governed by [`COMPATIBILITY.md`](COMPATIBILITY.md).

## [Unreleased]

### Added
- **SDK: resume a run after a crash.** `Runtime.resume_run(run_id)` attaches to an
existing governed run (it neither creates a new run nor cancels on error), so a
Python agent can pick its work back up from the last checkpoint after a `SIGKILL`.
The run keeps its server-side budget and already-spent usage, so it can't
overspend by restarting. See the [SDK README](sdks/python/README.md#resume-after-a-crash).

## [0.2.0] - 2026-06-04

A frictionless-adoption release: a one-line CLI install, three runnable key-free
Expand Down
19 changes: 19 additions & 0 deletions sdks/python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,25 @@ with rt.governed_run(name="research",
When the governor halts the run (token / dollar / loop / time budget), the next
`run.step()` — or a proxied model call — raises `rk.BudgetExceeded`.

## Resume after a crash

The daemon reloads non-terminal runs on restart with the budget and usage they had
already spent, so a `SIGKILL`'d run keeps enforcing without re-spending. Reattach to
it by id with `resume_run` and pick your work back up from the last checkpoint:

```python
with rt.resume_run(run_id) as run: # attaches; never creates or cancels
cp = run.latest_checkpoint() # the state you saved before the crash
start = cp["payload"]["cursor"] if cp else 0
for i in range(start, total): # skip the steps you already paid for
run.step() # counts against the SAME budget
# ... your work ...
run.checkpoint("step", {"cursor": i + 1})
```

The run resumes against whatever budget it had left, so it can't overspend by
restarting — `run.step()` still raises `rk.BudgetExceeded` at the original ceiling.

## Human-in-the-loop tools

Gate side-effecting tools on human approval (the daemon's policy decides what needs
Expand Down
27 changes: 27 additions & 0 deletions sdks/python/riskkernel/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,33 @@ def governed_run(self, name: Optional[str] = None,
finally:
_current_run.reset(token)

@contextmanager
def resume_run(self, run_id: str):
"""Attach to an existing governed run by id — the resume path after a crash.

Unlike governed_run, this neither creates nor cancels the run: the daemon
reloads non-terminal runs on restart with the budget and usage they had
already spent, so enforcement continues without re-spending. Fetch
``run.latest_checkpoint()`` to pick your work back up where it left off::

with rt.resume_run(run_id) as run:
cp = run.latest_checkpoint()
start = cp["payload"]["cursor"] if cp else 0
for i in range(start, total):
run.step() # counts against the SAME budget
...
run.checkpoint("step", {"cursor": i + 1})

Raises APIError(404) if the run id is unknown.
"""
data = self.client.get_run(run_id)
run = Run(self.client, data, self._poll, self._timeout)
token = _current_run.set(run)
try:
yield run
finally:
_current_run.reset(token)


# Module-level default runtime, configured from the environment, for the
# decorator/convenience API.
Expand Down
19 changes: 19 additions & 0 deletions sdks/python/tests/test_sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ def _read(self):

def do_GET(self):
p = self.path
if p == "/v1/runs/run-1":
return self._send(200, {"id": "run-1", "name": "t", "status": "running",
"usage": {"tokens": 0, "loops": STATE.steps}})
if p.startswith("/v1/memory/facts"):
return self._send(200, [{"namespace": "dev", "key": "db", "value": "sqlite"}])
if p.startswith("/v1/memory/entry"):
Expand Down Expand Up @@ -133,6 +136,22 @@ def test_governed_run_and_step_budget(self):
run.step()
self.assertEqual(cm.exception.reason, "loop_budget_exceeded")

def test_resume_run_attaches_without_creating(self):
# The post-crash path: resume an existing run by id. resume_run attaches a
# handle (no new run, no cancel) that keeps stepping/checkpointing against
# the SAME run, and reads the checkpoint it left off at.
with self.rt.governed_run(name="t", budget=self.rt.budget(loops=5)) as run:
run.step() # one step before the "crash"
run.checkpoint("before-crash", {"cursor": 1})
rid = run.id

with self.rt.resume_run(rid) as resumed:
self.assertEqual(resumed.id, rid) # same run, not a new one
self.assertEqual(rk.current_run().id, rid) # set as the current run
cp = resumed.latest_checkpoint()
self.assertEqual(cp["payload"]["cursor"], 1) # where we left off
self.assertEqual(resumed.step(), 2) # continues the same step count

def test_checkpoint_roundtrip(self):
with self.rt.governed_run(name="t") as run:
run.checkpoint("after", {"cursor": 7})
Expand Down
Loading