|
| 1 | +# Authoring `cao workflow` scripts |
| 2 | + |
| 3 | +This guide is for anyone writing a Python script to run under |
| 4 | +`cao workflow run --script <path>` (the "script tier" — a full Python |
| 5 | +program driving one or more agent steps, as opposed to the declarative YAML |
| 6 | +tier). It covers the `cao_workflow` shim's contract, when to reach for a |
| 7 | +script instead of YAML, the determinism obligation resume relies on, and the |
| 8 | +resume boundary itself. |
| 9 | + |
| 10 | +## The contract: `run_step` and `emit_output` |
| 11 | + |
| 12 | +Your script's entire interface to the CAO server is the `cao_workflow` |
| 13 | +package — a thin, stdlib-only client library (`urllib` transport, zero |
| 14 | +`cli_agent_orchestrator` imports) that runs *inside your script's own |
| 15 | +process*, not inside the server. |
| 16 | + |
| 17 | +```python |
| 18 | +from cao_workflow import run_step, emit_output |
| 19 | + |
| 20 | +handle = run_step("kiro_cli", "reviewer", "review this diff") |
| 21 | +print(handle.output) # handle.terminal_id, handle.status also available |
| 22 | + |
| 23 | +emit_output({"reviewed": True}) |
| 24 | +``` |
| 25 | + |
| 26 | +- **`run_step(provider, agent, prompt, *, step_id=None, timeout=None, **opts) -> StepHandle`** |
| 27 | + runs one agent step through the same shared substrate the server's own |
| 28 | + handoff path uses. It resolves your run's identity from the environment, |
| 29 | + posts to `/terminals/run-step`, and returns a `StepHandle` with |
| 30 | + `.step_id`, `.terminal_id`, `.output`, `.status`. |
| 31 | +- **`emit_output(value)`** prints the run-level `CAO_WORKFLOW_OUTPUT:{json}` |
| 32 | + sentinel line the server scans once your script exits — a one-line |
| 33 | + convenience so you never hand-format the prefix/JSON encoding yourself. |
| 34 | + It is pure `print()`; no HTTP call, no state. |
| 35 | +- **Errors are typed and never retried:** `ShimIdentityError` (identity env |
| 36 | + missing — nothing was attempted), `ShimTransportError` (network failure, |
| 37 | + wraps the underlying `urllib` error), `ShimHTTPError` (non-2xx response, |
| 38 | + carries `.status`/`.body`). All four (`ShimError` plus these three |
| 39 | + subclasses) are importable from `cao_workflow` directly: |
| 40 | + `from cao_workflow import run_step, ShimHTTPError`. |
| 41 | +- **`step_id` is required for concurrent fan-out.** If you call `run_step` |
| 42 | + from more than one thread (e.g. via `concurrent.futures`), pass an |
| 43 | + explicit, stable `step_id` per call. See "Fan-out and `step_id`" below — |
| 44 | + this is not optional ergonomics, it is a correctness requirement. |
| 45 | +- **`reuse_terminal_id` is not supported through `run_step`.** The shim |
| 46 | + always sends identity `env_vars`, and the server unconditionally rejects |
| 47 | + `env_vars` + `reuse_terminal_id` together (422) — `run_step` fails fast |
| 48 | + client-side with a `ShimError` instead of round-tripping an opaque 422. If |
| 49 | + you genuinely need terminal reuse without the identity fence, call the |
| 50 | + HTTP API directly. |
| 51 | + |
| 52 | +## YAML vs. script: which tier should this workflow use? |
| 53 | + |
| 54 | +Use the **YAML tier** (`cao workflow validate`/`list`/`get`/`run` against a |
| 55 | +declarative spec) when your workflow is a fixed sequence or simple |
| 56 | +branch/loop expressible in the YAML grammar — it is simpler to author, |
| 57 | +lint, and reason about, and it is the tier most of CAO's tooling assumes by |
| 58 | +default. |
| 59 | + |
| 60 | +Use a **script** (this guide) when your workflow's control flow needs |
| 61 | +something YAML can't express yet: nontrivial branching logic, real |
| 62 | +concurrent fan-out (`concurrent.futures`/`threading`), or per-iteration |
| 63 | +Python computation over agent output. A script is a full Python program — |
| 64 | +more power, more responsibility (see the determinism obligation next). |
| 65 | + |
| 66 | +If you're unsure, start with YAML. Reach for a script only when you hit a |
| 67 | +concrete limitation. |
| 68 | + |
| 69 | +## The determinism obligation (and repeated work on resume) |
| 70 | + |
| 71 | +`run_step` never retries, backs off, or reconnects. A client-side retry after |
| 72 | +an unknown-completion-state transport failure could issue the same agent work |
| 73 | +twice, so failures are returned to your script unchanged. |
| 74 | + |
| 75 | +This puts an obligation on **your script**: its control flow and the |
| 76 | +prompts it sends must be deterministic across runs of the *same* script |
| 77 | +source. If your script's behavior can vary run-to-run — a `random()` call |
| 78 | +that isn't seeded, a prompt that embeds `datetime.now()`, branching on |
| 79 | +wall-clock time — a **resume** can repeat different work from the original |
| 80 | +attempt. The current runtime does not replay completed calls or reject that |
| 81 | +divergence: it re-executes the frozen script from the top, including every |
| 82 | +`run_step` call. Determinism makes that repeated work predictable, but your |
| 83 | +workflow must tolerate it being issued again. |
| 84 | + |
| 85 | +## Fan-out and `step_id` |
| 86 | + |
| 87 | +`run_step`'s step key defaults to a lock-guarded sequential counter |
| 88 | +(`call-1`, `call-2`, ...) when you omit `step_id`. That counter is |
| 89 | +**race-free** under concurrent callers — the lock guarantees no two calls |
| 90 | +ever get the same key — but it is **not safe for fan-out**. Thread |
| 91 | +scheduling, not the counter's correctness, decides which call claims which |
| 92 | +`call-N`, so two runs of the same fan-out script can assign `call-1`/ |
| 93 | +`call-2` to different logical calls depending on how the OS scheduled your |
| 94 | +threads that run. On resume, that reassignment makes journal history and |
| 95 | +diagnostics refer to different logical calls under the same `call-N` key. |
| 96 | + |
| 97 | +**The rule:** any time you call `run_step` from more than one thread |
| 98 | +(`concurrent.futures.ThreadPoolExecutor`, manual `threading.Thread`), pass |
| 99 | +an explicit, stable `step_id` per call: |
| 100 | + |
| 101 | +```python |
| 102 | +def _run_shard(shard): |
| 103 | + return run_step("kiro_cli", "reviewer", f"review {shard}", step_id=f"shard-{shard}") |
| 104 | + |
| 105 | +with ThreadPoolExecutor(max_workers=3) as pool: |
| 106 | + futures = [pool.submit(_run_shard, s) for s in ("alpha", "beta", "gamma")] |
| 107 | +``` |
| 108 | + |
| 109 | +The linter (`cao workflow validate`) does not infer concurrency or check this |
| 110 | +rule. It reports syntax errors, disallowed or unverifiable dynamic imports, |
| 111 | +and imports associated with nondeterminism. Supplying stable `step_id` values |
| 112 | +for fan-out is the author's responsibility. |
| 113 | + |
| 114 | +## Resume re-executes the frozen script |
| 115 | + |
| 116 | +`run_step()` behaves **identically** whether the surrounding script is a |
| 117 | +fresh run or a resume drive. There is no `if resuming:` branch inside the |
| 118 | +shim. `cao workflow resume` re-executes the frozen source snapshot from the |
| 119 | +top with a new generation token, and each `run_step` call makes a new HTTP |
| 120 | +request that executes the agent step again. Completed calls are journaled, |
| 121 | +and a replay lookup primitive exists in the journal layer, but it is not |
| 122 | +connected to the run-step route and cannot currently suppress repeated work. |
| 123 | + |
| 124 | +Design scripts so repeated steps are acceptable, or add application-level |
| 125 | +idempotency around external side effects. `CAO_WORKFLOW_RESUME=1` is present |
| 126 | +in the resumed subprocess environment for code that must distinguish the |
| 127 | +drive, but the `cao_workflow` shim itself does not branch on it. |
| 128 | + |
| 129 | +## The `reuse_terminal_id` 422 trap |
| 130 | + |
| 131 | +If you pass `reuse_terminal_id` through `run_step(..., reuse_terminal_id=...)`, |
| 132 | +you'll get a `ShimError` immediately, before any network call: |
| 133 | + |
| 134 | +``` |
| 135 | +ShimError: reuse_terminal_id is not supported by run_step() — the shim |
| 136 | +always sends env_vars (RUN_ID/GENERATION/STEP_ID), and the server rejects |
| 137 | +env_vars + reuse_terminal_id together (422). Omit reuse_terminal_id, or |
| 138 | +call the HTTP API directly if you need to reuse a terminal without the |
| 139 | +identity fence. |
| 140 | +``` |
| 141 | + |
| 142 | +This isn't a shim bug or an arbitrary restriction: `run_step` **always** |
| 143 | +populates the identity `env_vars` fence (`CAO_WORKFLOW_RUN_ID`/ |
| 144 | +`GENERATION`/`STEP_ID`), and the server's own request validator |
| 145 | +unconditionally rejects any request carrying both `env_vars` and |
| 146 | +`reuse_terminal_id` — the combination can never legitimately round-trip. |
| 147 | +Passing it through `**opts` would always produce an opaque 422; the shim |
| 148 | +fails fast instead so the mutual exclusivity is visible immediately. If you |
| 149 | +need terminal reuse without the identity fence, that's a case for calling |
| 150 | +`/terminals/run-step` directly over HTTP, outside the shim. |
| 151 | + |
| 152 | +## Examples |
| 153 | + |
| 154 | +Each example under [`docs/examples/`](examples/) demonstrates one of these |
| 155 | +patterns end-to-end, with a matching e2e test proving it runs: |
| 156 | + |
| 157 | +- [`loop_example.py`](examples/loop_example.py) — sequential loop, default |
| 158 | + `step_id` counter. |
| 159 | +- [`conditional_example.py`](examples/conditional_example.py) — branching |
| 160 | + control flow, explicit `step_id` per branch. |
| 161 | +- [`fanout_example.py`](examples/fanout_example.py) — concurrent fan-out via |
| 162 | + `ThreadPoolExecutor`, explicit `step_id` per shard (the fan-out rule |
| 163 | + above, applied). |
| 164 | +- [`loop_raw_http_example.py`](examples/loop_raw_http_example.py) — the |
| 165 | + SAME loop shape with **no** `cao_workflow` import at all, using raw |
| 166 | + `urllib` directly against the identity env vars `cao workflow run` |
| 167 | + injects. Proves the shim is a convenience, not a requirement — a script |
| 168 | + is free to skip it entirely and talk to `/terminals/run-step` on its own. |
0 commit comments