Wait commands
Server-side condition waiting (AUTHAPI-25). job wait waits until a runtime/editor member condition holds — evaluated on the Editor's per-frame loop — and can act in the same frame the condition first becomes true (e.g. screenshot the moment a match-end screen appears). It replaces the dominant agent anti-pattern of client-side polling loops: read a value via script eval, sleep in the client, repeat. That loop is token-expensive and slow, and its client round-trips can miss short-lived states entirely (a 2-second screen is easy to miss at ~1–2 s per poll). A server-side condition is a cheap per-frame delegate that can act atomically the frame it fires.
Sync vs async: pick the right mode first
/api/exec executes one command at a time — a synchronous job wait holds that queue for its entire duration. The Editor itself never freezes (each poll is marshaled onto the main thread between sleeps), but every other command queues behind the wait until it resolves. That includes any command whose effect your condition is waiting for: if the condition needs a mutation you haven't issued yet, the mutation can never run, and the wait self-deadlocks until its timeout.
Use a synchronous wait only when BOTH hold:
- the wait is short — comfortably under your client's HTTP timeout (typically < 30 s), and
- the condition does not depend on any command you still need to issue (it will be satisfied by something already in motion: play-mode gameplay, a running bake, a timer, ...).
For everything else, pass async=true: you get a waitId back immediately, other commands keep executing, and you poll job wait_status (and cancel with job wait_cancel). This is the default choice for long waits and for any "issue command, then wait for its effect" flow.
A synchronous wait submitted as a detached job (POST /api/exec with "job": true) remains cancelable: POST /api/job/cancel interrupts the sleep loop and resolves the wait as canceled.
Member resolution, coercion, and the read-only security policy are intended to be shared with the invoke/query surface (AUTHAPI-16). Until that shared resolver lands,
job waitships a self-contained resolver with the same rules that the two will converge on. Type and member-chain resolution is cached per wait — only the live target instance (anObjectReftarget or afindTypeinstance) is re-resolved each poll.
job wait
Wait until a member condition holds, then optionally act in the same frame.
| Parameter | Required | Default | Description |
|---|---|---|---|
condition |
yes | – |
The condition object (see below). |
timeout |
no | 30 |
Maximum seconds to wait (clamped to 0–600). A synchronous wait reports timedOut slightly before it, so the reply arrives first. |
poll_interval_ms |
no | 100 |
How often to re-check (clamped to 16–5000 ms; NaN/Infinity are rejected). The sleep never exceeds the remaining budget, so a large interval cannot stretch a short timeout. |
on_met |
no | – |
Follow-up run in the same frame the condition first holds: { capture: { view, source, save_path, width, height, include_image }, pause }. capture.source (game view only): camera (default) renders a camera and misses Screen Space - Overlay UI; screen captures the composited game view including overlay canvases (HUDs), Play Mode only. |
return_history |
no | false |
Include the observed { value, elapsedMs } samples. A ring buffer keeps the last 200 samples, so the tail near the transition is retained (the interesting part), not the first 200 identical observations. |
tolerate_missing |
no | false |
Treat resolution failures (type/member/instance not found) as condition-not-met and retry until timeout, instead of failing fast. Enables "wait until X exists" — e.g. waiting for a spawned object via findType. On timeout the last resolution error is included. |
async |
no | false |
Return a waitId immediately and evaluate in the background; poll job wait_status, cancel with job wait_cancel. Use for long waits and whenever the condition depends on a command you still need to issue. |
The condition object
| Field | Required | Description |
|---|---|---|
member |
yes | Dotted member path. With no target/findType it is a fully-qualified static path (e.g. UnityEditor.EditorApplication.isPlaying); with target/findType it walks instance members (e.g. State, Health.CurrentHealth). Read-only fields/properties only — write-only members and methods are rejected. |
target |
no | Object handle (instanceId / hierarchyPath / guid / path / globalId) to read instance members from. Re-resolved every poll, so the referenced object may appear mid-wait (combine with tolerate_missing). |
findType |
no | Fully-qualified UnityEngine.Object type name; the member walks from a live instance. Persistent objects (prefab/asset files on disk) and hidden editor internals (hideFlags != None) are excluded, so the wait never silently binds to a prefab asset instead of the scene instance. Scene instances are preferred; if several candidates remain, the lowest instance id is chosen deterministically and a note on the result reports the ambiguity — pass target to select one explicitly. |
op |
no | equals (default) | notEquals | greaterThan | lessThan | contains (strings) | changed (met on the first change from the initially observed value; needs no value). |
value |
conditional | Comparison operand, coerced to the member's type (primitive, enum name, or string). Required for every op except changed. When either side is a float, equals/notEquals compare at float precision (widening a float to double manufactures noise digits — 0.9f would otherwise never equal 0.9). |
op: changed is for value-like members — primitives, enums, and strings. It compares the member's current value against the first observed value, so content mutations inside a reference-typed member (a list gaining an element, a field on a referenced object changing) are not detected: the reference itself must change. Likewise, value/initialValue/history snapshots of mutable reference types reflect the object's state at serialization time, not at observation time.
Returns: WaitResult
{
"state": "met", // pending | met | timedOut | interrupted | canceled | failed | not_found
"met": true,
"timedOut": false,
"member": "MatchManager.Instance.State",
"op": "equals",
"value": "Ended", // most recently observed value
"initialValue": "Playing", // first observed value (baseline for op=changed)
"elapsedMs": 512, // frozen at the terminal transition
"framesObserved": 6,
"note": "…", // present only for non-fatal resolution notes (e.g. ambiguous findType)
"capture": { "savedPath": "Screenshots/victory.png" }, // present only with on_met.capture
"waitId": "…" // present only for async submissions / status snapshots
}
- A timeout returns
met:false, timedOut:truewith the last observedvalue— distinct from a resolution/evaluation error, which returnsstate:"failed"with anerrormessage (unlesstolerate_missingturns resolution errors into retries; the wait then times out with the last resolution error attached). - If the main thread stalls past the per-poll marshal budget (~60 s: a huge import, a modal bake), the poll is counted as missed and the wait keeps going until its own budget expires — a stall does not turn the wait into a generic error.
- The sync result is well under 2 KB without
history.
job wait_status
Get the status/result of an async wait (job wait with async=true).
| Parameter | Required | Default | Description |
|---|---|---|---|
wait_id |
yes | – |
The id returned by job wait when async=true. |
Returns: WaitResult (state:"not_found" if the id is unknown — async waits do not survive a domain reload; completed waits are retained up to a bounded count and pruned oldest-first the next time a job wait is submitted, so a session that never submits another wait keeps its last results queryable).
job wait_cancel
Cancel an async wait.
| Parameter | Required | Default | Description |
|---|---|---|---|
wait_id |
yes | – |
The id to cancel. |
Returns: WaitResult. If the wait was still active, it resolves to state:"canceled". If it had already reached a terminal state (met/timedOut/failed/interrupted) before the cancel request arrived, that actual state is returned unchanged — the cancel had no effect, and reporting a fake canceled here would misreport whether an on_met follow-up (capture/pause) actually ran.
Migration: replace a client polling loop with one call
Before — an agent screenshots a match-end screen by polling with script eval (dozens of round-trips, and the 2-second screen is easy to miss):
# ~60 client round-trips, each ~1–2s:
loop:
state = script eval "MatchManager.Instance.State" # read
if state == "Ended": break
sleep 1 # client-side sleep
capture game_view --source screen --save_path Screenshots/victory.png
After — one async wait that captures atomically in the frame the condition first holds, while other commands stay available (the match may take minutes, and you may still need to issue commands that drive it):
# 1. Arm the wait; returns { waitId } immediately:
job wait --condition '{"member":"MatchManager.Instance.State","op":"equals","value":"Ended"}' \
--timeout 300 \
--on_met '{"capture":{"view":"game","source":"screen","save_path":"Screenshots/victory.png"}}' \
--async true
# 2. Keep working: start the match, drive gameplay, run other commands...
# 3. Collect the result (poll until state != "pending"):
job wait_status --wait_id <waitId>
Only use the synchronous form when the wait is short and self-contained — e.g. play mode is already running and the state flips within a few seconds:
job wait --condition '{"member":"MatchManager.Instance.State","op":"equals","value":"Ended"}' --timeout 10
Waiting for something to exist (spawn waits)
By default the first failed resolution fails the wait (state:"failed") — that catches typos fast. To wait for an object or member that does not exist yet, pass tolerate_missing:true:
job wait --condition '{"findType":"MyGame.BossController","member":"IsActivated","op":"equals","value":true}' \
--tolerate_missing true --timeout 120 --async true
Resolution failures are then treated as condition-not-met and retried every poll until the object appears (or the wait times out, with the last resolution error attached).
Interruption and lifecycle
- Concurrent waits are capped (8); a further submission is rejected with a structured error telling you to
job wait_cancelone or let one finish. - A domain reload (
beforeAssemblyReload) or exiting play mode resolves every active wait tostate:"interrupted"rather than hanging or leaking. Entering play mode does not interrupt: with domain reload disabled ("Enter Play Mode Options"), a wait armed in edit mode survives into play mode — the natural way to arm a capture before pressing play. (With domain reload enabled, the reload interrupts it anyway.) on_met.pausepauses play mode on the frame the condition holds (no-op outside play mode).- If the server is stopping while a synchronous wait is pending, the wait resolves as
interrupted("server stopping") instead of evaluating against a shutting-down editor.
Result contract details
erroris set only when the wait itself ends abnormally (failed,interrupted,canceled, or the surfaced last resolution error on a tolerated timeout). A failedon_metfollow-up (capture/pause) is reported separately asonMetErrorwhilestatestaysmet— the condition did hold; checkonMetErrorbefore trustingcapture.condition.targetandcondition.findTypeare mutually exclusive (rejected up front).- Member paths resolve public readable fields/properties only.
- A destroyed Unity object mid-path fails structurally; as the leaf value it is observed
as
null(Unity's own==semantics), so "wait until destroyed" is{ "op": "equals", "value": null }— thevaluekey must be present and explicitlynull; omitting it is rejected for every op exceptchanged. tolerate_missingretries the evaluator's STRUCTURED failures: resolution failures (type/member/instance not found, unless permanently malformed) and evaluation errors ("cannot compare yet" — e.g. an ordering op while the member is still null); the last error is surfaced if the wait times out. A member getter that THROWS fails the wait immediately, tolerated or not.- A bare type name matching several loaded types binds deterministically (lowest full name) and
surfaces a
note— use the fully-qualified name to disambiguate.
Interaction with job batch
job wait is rejected inside a job batch (not_batchable): the batch holds the main thread for its
whole turn, so the editor state a condition watches cannot change while the wait polls — it would
just burn its timeout. Run an async job wait before or after the batch and poll job wait_status
(both job wait_status and job wait_cancel are quick reads and stay batchable).
Interaction with the startup settle gate
job wait is a background command (MainThreadRequired=false), so it is exempt from the settling busy-gate (AUTHAPI-35): it is accepted even while the Editor is still importing/compiling after a cold start. A wait polling during that window observes a half-ready editor — types may not be loaded and objects may not exist yet. Combine with tolerate_missing:true for a deliberate wait-until-ready pattern, or gate on /api/status reporting ready first if you want fully settled reads.
See Creating commands and Connectivity.