Skip to content

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.5 to 1.0 seconds. Snappy enough for human attention.
  • Operational scripts (CI, scheduled jobs)2.0 to 5.0 seconds. Reduces request volume for long-running jobs where snappy feedback isn't needed.
  • Long-running background workers10.0+ seconds. Pair with a generous timeout.

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:

seen: list = []
try:
    async for event in client.platform.runs.tail_trace(run_id, timeout=60):
        seen.append(event)
except TimeoutError:
    print(f"timed out with {len(seen)} events streamed")
    raise