Polling and waiting¶
Two helpers on RunsClient cover the two common run-lifecycle waiting
patterns:
wait_for_completion()— block until a run reaches a terminal status, returning the final status. Best when you only care about the outcome and will inspect the trace afterwards.tail_trace()— stream trace events as they arrive, returning an async iterator. Best when you want to surface progress live (Studio editors, CLIs, dashboards).
Both follow the same polling-with-status-check pattern under the hood; they differ in what they yield to the caller.
Async create (async_mode=true)¶
A synchronous runs.create() blocks until the run finishes and returns
its result. Passing async_mode=true instead tells the platform to
accept the run and dispatch it in the background:
created = await client.platform.runs.create(
{"input": "Summarize the latest activity.", "spec_path": "my-spec.yaml", "async_mode": True}
)
status = await client.platform.runs.wait_for_completion(created.run_id)
Since kneo_serv 0.11.0 an async create responds with HTTP 202
Accepted (it was 200 through 0.10.x); a synchronous create still
returns 200. The client treats any 2xx as success, so create()
returns the same RunCreateResponse carrying the run_id in both
cases — you don't branch on the status code. From there, poll with
wait_for_completion() or stream with tail_trace() below. See
examples/06_async_run.py
for the end-to-end flow.
The agent-side client.agent.specs.run() (Studio's iterate-and-test
route, POST /v1/specs/run) shares the same async semantics since
kneo_serv 0.12.0/1.0.0: {"async_mode": True} returns a 202 with the
queued run_id, which you poll with the same wait_for_completion().
created = await client.agent.specs.run(
{"input": "Summarize the latest activity.", "spec_path": "my-spec.yaml", "async_mode": True}
)
status = await client.platform.runs.wait_for_completion(created.run_id)
wait_for_completion()¶
status = await client.platform.runs.wait_for_completion(
run_id,
poll_interval=1.0, # seconds between GETs to /v1/runs/{run_id}
timeout=600, # total budget; None to wait indefinitely
)
print(status.status) # "completed" | "failed" | "cancelled" | ...
The default terminal_statuses set is {"completed", "failed",
"cancelled", "timed_out", "expired"} — the platform's canonical
terminal set. timed_out (the platform's timeout sweep) and expired
(a blocked human task's on_timeout: fail policy) became reachable in
kneo_serv 0.9.0 and are terminal by default; no custom set is needed
to stop on them. Pass a custom set to treat additional states as
terminal:
# Stop as soon as the run pauses for human review.
status = await client.platform.runs.wait_for_completion(
run_id,
terminal_statuses={"blocked"},
)
Raises TimeoutError if the deadline elapses before any terminal
status is reached.
tail_trace()¶
async for event in client.platform.runs.tail_trace(run_id, poll_interval=1.0):
print(event["event_type"], event["payload"] if "payload" in event else None)
Trace-event items are generated "open" models, not dicts — they support
bracket lookup (event["key"]) and membership tests ("key" in event),
but not dict conveniences like .get().
Internally tail_trace walks /v1/runs/{run_id}/trace with ascending
offset and yields each event as it lands. When the run reaches a
terminal status (configurable, same defaults as wait_for_completion)
the helper does one final drain pass to capture events emitted between
the last poll and the status transition, then returns.
Key arguments (all optional):
| Arg | Default | Notes |
|---|---|---|
start_offset |
0 |
Resume tailing from a known position. |
page_size |
100 |
limit per /v1/runs/{run_id}/trace fetch. |
poll_interval |
1.0 |
Seconds to sleep when no new events arrived. |
timeout |
None |
Total budget for reaching terminal status. Does not apply during the post-terminal drain — that always runs to completion. |
terminal_statuses |
{"completed", "failed", "cancelled", "timed_out", "expired"} |
Override to treat other states as terminal. |
event_type |
None |
Filter passed through to trace. |
Raises TimeoutError if the deadline elapses while waiting for a
terminal status. Events yielded so far stay yielded — the caller has
already consumed them.
When to use which¶
| Want | Use |
|---|---|
| Just the final outcome | wait_for_completion() |
| Final outcome + the full trace at the end | wait_for_completion() then runs.trace() |
| Events as they arrive (live UI / log tail) | tail_trace() |
| First N events of a finished run | runs.trace(limit=N) directly |
| Resume an interrupted tail | tail_trace(start_offset=...) |
Polling cadence¶
A rule of thumb: pick poll_interval so that p99 perceived latency
roughly equals poll_interval / 2.
- Interactive UIs (Studio, dashboards) —
0.5to1.0seconds. Snappy enough for human attention. - Operational scripts (CI, scheduled jobs) —
2.0to5.0seconds. Reduces request volume for long-running jobs where snappy feedback isn't needed. - Long-running background workers —
10.0+ seconds. Pair with a generoustimeout.
Don't go below 0.5 seconds without a specific reason — at that rate
you're spending more on request overhead than you save in latency,
and the platform's retry / backoff machinery is also factoring in.
Custom terminal statuses¶
The platform reports several non-final statuses (running, queued,
blocked — paused for human review) that the default terminal set
excludes.
The most common override is treating human-review pauses as terminal — useful when an operator-facing tool wants to surface "this run needs your attention" rather than continue polling:
status = await client.platform.runs.wait_for_completion(
run_id,
terminal_statuses={
"completed", "failed", "cancelled", "timed_out", "expired", "blocked",
},
)
if status.status == "blocked":
# Surface a UI prompt; resume via the human-tasks API.
...
tail_trace() accepts the same kwarg with the same semantics.
TimeoutError handling¶
Both helpers raise TimeoutError rather than returning a partial
result, because returning a "not yet terminal" status silently is
easy to misuse. Pattern:
try:
status = await client.platform.runs.wait_for_completion(
run_id, timeout=300
)
except TimeoutError:
# Decide whether to cancel, escalate, or keep waiting with a fresh budget.
current = await client.platform.runs.get(run_id)
if current.status == "running":
await client.platform.runs.cancel(run_id)
raise
For tail_trace, events yielded before the timeout stay consumed: