aion-server 0.26.0

Aion workflow server library: HTTP, gRPC, WebSocket, and worker endpoints. Run it with the `aion` binary from the aion-cli crate.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
//! The assistant workflow: an operator-paced aion-authoring chat session,
//! authored in AWL.
//!
//! PROVISION IS SERVER-RUN: `assistant_provision` declares a command body, so
//! the server executes it from the deployed contract as the server's own user,
//! with no sandbox — no worker serves it. Its environment is cleared to PATH
//! only, its cwd is inherited from the server process, and the SERVER states
//! where workspaces live: the literal `{workspace_root}` placeholder in the
//! body's command is expanded at dispatch to the server's own aion home
//! `clones/` directory (reported in the startup banner's `workspace_root`
//! field). The operator supplies nothing but `objective` and `repo_path`.
//!
//! Pure orchestration over one server-run provisioning command and one
//! recorded worker action:
//!
//!   assistant_provision -> [ assistant round ; await operator ]*   [operator-paced]
//!
//! Round one dispatches the `assistant` agent action with the opening prompt
//! THIS DOCUMENT composes — the full working contract (the consts below), the
//! repository-attached or scratch branch fragment, and the operator's
//! objective. Every later round is the operator's continuation message
//! VERBATIM, dispatched into the SAME norn session (the worker pins
//! `{workflow_id}-assistant` + `--resume-if-exists`, so repeated dispatches of
//! the one action type resume one conversation and the contract is never
//! re-sent).
//!
//! Between rounds the session parks on the ONE control signal,
//! `assistant_continue`. Continue and end share the signal name and
//! discriminate in the payload: `end: true` finishes cleanly, a non-blank
//! `message` starts the next round, and a blank message is a no-op nudge that
//! is absorbed by the inner wait loop without spending a round.
//!
//! WHY THIS EXISTS: the Gleam-authored `assistant` package cannot start at all
//! under queue-routed serving. A project manifest's `DeclaredActivity` has one
//! field, so every activity is committed UNSCOPED and start is refused with
//! `NO_QUEUE_DECLARATION` (docs/design/aion-authoring/
//! GLEAM-PATH-CANNOT-START-FINDING.md). AWL emits a scoped contract, so it is
//! the only surface that can author this workflow. The differences between
//! this document and the Gleam original are recorded in full in
//! docs/design/aion-authoring/ASSISTANT-AWL-PORT.md — read it before changing
//! anything here.
//!
//! THE QUEUE IS PRIVATE, AND IT IS DERIVED. The `worker` block below is named
//! `assistant` — this workflow's own logical name — and NEVER `default`. It is
//! not a preference and it is not configurable: the server verifies at load
//! that the embedded document's declared queue is exactly its workflow type
//! (`crates/aion-server/src/assistant/document.rs`, `private_task_queue`), and
//! refuses the document if it declares anything else.
//!
//! The reason is #200. `default` is where an out-of-box worker comes up when
//! nobody has told it otherwise, and contract admission holds a registering
//! worker against EVERY reachable contract on its queue at once. While this
//! document sat on `default`, the first worker a newcomer started was refused
//! for not advertising `assistant` — the built-in assistant starved the very
//! workers it was meant to welcome. `default` belongs to the out-of-box
//! workers; the assistant lives on its own name.
//!
//! DELIBERATE OMISSION — no `timeout` in the header. An agent round carries no
//! timeout by design (a wedged round is cancelled or intervened, never timed
//! out), and the session itself is operator-paced: a real run has parked on
//! this signal for 28 days and resumed healthy. A workflow timeout would kill
//! exactly the sessions this workflow exists to keep alive.
workflow assistant
  // The operator's opening ask. Must not be blank — there is nothing to open
  // the session with. AWL has no input-validation construct, so unlike the
  // Gleam original this is documented rather than enforced (see the ledger).
  input objective: String
  // The aion repository the session grounds its answers in: a local path or
  // clone URL. Empty is the documented "no repository, scratch workspace"
  // mode — the assistant then says what it cannot verify.
  input repo_path: String

  // The ONE control signal the session listens on. Both fields are REQUIRED
  // on the wire. Send `{"message": "...", "end": false}` to continue and
  // `{"message": "", "end": true}` to finish.
  //
  // The reason has CHANGED and the requirement has not. AWL used to be unable
  // to read the value of an optional field at all — narrowing keyed on a
  // binding name, so `nudge.end` could never become a `Bool`. That is fixed
  // (#97-B): a guard now proves a PATH, and `when nudge.end is present and
  // nudge.end` reads it. What still forces both fields is this flow's own
  // shape, not the language: `nudge.end` is read where nothing has proved it
  // — inside the `until` condition AND inside the `Round(...)` construction in
  // the loop body, which sits under no guard. Making the fields optional means
  // restructuring those reads behind guard arms, which changes the operator's
  // wire contract on a session that can be parked for weeks. That is a
  // deliberate change to make on purpose, not a consequence of a checker fix.
  signal assistant_continue: Continuation

  // The read-only surface: what an operator gets back from
  // `POST /workflows/query {"query_name": "assistant_status"}`. It reads live
  // in-memory state, appends no event, and answers instantly — the one thing
  // that proves a session parked for weeks is alive rather than wedged. Same
  // name and same shape as the Gleam original's query.
  query assistant_status: AssistantStatus

  outcome ended:   type Session, route success
  outcome capped:  type Session, route success
  outcome stalled: type Session, route success

// ---------------------------------------------------------------------------
// The opening prompt, as document constants.
//
// Ported from examples/agent-dev/worker/src/handlers/opening_prompt.rs — the
// worker composed this text when provisioning also materialised skill
// resources into the workspace; the declared body materialises nothing, so
// the prompt now points at `examples/assistant/resources/` inside the clone
// (attached case) or at the public clone command (scratch case), and the
// composition lives HERE, next to the branch that selects it.
//
// Raw strings are VERBATIM: every newline and space between the `"""`
// delimiters is part of the value, which is why several constants open or
// close mid-sentence — the concatenation seams in step `session` are exact.
// ---------------------------------------------------------------------------

// The contract's opening paragraph and the head of the workspace note, up to
// the workspace path the document splices in.
const contract_head = """You are the aion AWL authoring assistant: a long-running session helping an operator understand aion and author checked `.awl` workflows. The operator talks to you through the aion ops console; each of their messages arrives as a new prompt in this same session, so you keep full memory of the conversation.

## Your workspace
Your workspace for this session is `"""

// Closes the workspace path and states the working rules as a RULE, not a
// claim about this launch — because this document has more than one serving
// path and they differ. Served by `aion worker agent` from the shipped
// `harness` section, the agent now starts in a DECLARED directory — the
// section's `cwd "{workspace_root}"`, this box's own clones root — but that is
// the root ABOVE the session workspaces, not this session's workspace, and no
// tool is confined (the old `--workspace-root`/`-C` pair is still absent; see
// the harness narration). Served by scripts/setup.sh → examples/agent-dev/
// worker, the worker passes that pair and the agent IS confined and started in
// the session workspace itself. A sentence asserting either state lies on the
// other path, so the prompt instructs conduct that is correct on both; the
// spike document and ENVIRONMENT.md carry the same sentence for the same
// reason.
const workspace_confinement = """`. Do not assume you are confined to that directory or started inside it — confinement depends on how this worker was launched. `cd` into the workspace before your first shell command and start every later shell command from it, address files by absolute paths under it, and create nothing outside it."""

// Workspace-note tail, scratch branch.
const scratch_workspace = """ It is a scratch git workspace — no aion repository was provided for this session."""

// Workspace-note tail, repository-attached branch: opens before the
// operator's `repo_path` and closes after it.
const attached_workspace_open = """ It is a clone of the aion repository at `"""
const attached_workspace_close = """`; author `.awl` workflows inside the clone (e.g. under `examples/`)."""

// Ground-truth head, shared by both branches. Opens with the blank line that
// separates it from the workspace note and deliberately ends mid-sentence:
// each branch fragment completes the sentence.
const ground_truth_head = """

## Ground truth
Your PRIMARY resources are your skill documents — the distilled operating manual for this exact job, kept in the aion repository at `examples/assistant/resources/`:
- `ENVIRONMENT.md` — AWL preflight, workspace semantics, and the local/server boundary (read FIRST)
- `AWL-AUTHORING.md` — the checked `.awl` authoring method from contract through observation
- `COMMANDS.md` — exact AWL check/format/schema/emit/deploy/run and observation commands
- `AWL-REFERENCE.md` — the checked AWL workflow language surface
- `WORKERS.md` — current worker layers, task queues, shell manifests, and the host-native shell-worker law
For anything deeper, consult the aion repository itself — never answer repo-level questions from memory"""

// Ground-truth tail, repository-attached branch.
const attached_ground_truth = """. Your workspace IS a clone of the aion repository — read the skill documents, and everything else, directly from the clone."""

// Ground-truth tail, scratch branch. There are no skill documents on disk
// until the assistant clones the repository itself.
const scratch_ground_truth = """. No repository was attached to this session, so the skill documents are not on disk yet; when you need them — or anything else in the repository — clone it into your workspace (`cd` there first): `git clone --depth 1 https://github.com/ablative-io/aion.git`. The repository is private, so the clone only succeeds where this host has git credentials — if it fails, say so, answer only what you can stand behind, and note to the operator that repo-dependent questions need either host credentials or a session started with the repository path set."""

// Everything after the branch point: the valuable-paths list, the authoring
// method, the honesty rules, and the heading the operator's objective is
// appended under.
const contract_tail = """

The most valuable repository paths:
- `examples/awl-hello/awl_hello.awl` — minimal checked workflow
- `examples/dev-brief/awl/` and `examples/remediation-packet/awl/` — production AWL flows and child workflows
- `crates/aion-awl/tests/fixtures/rev2/**/valid/*.awl` — accepted grammar and checker proofs
- `crates/aion-awl/src/` — parser, checker, printer, compiler
- `crates/aion-cli/src/awl.rs`, `one_motion.rs`, and `main.rs` — the real check/emit/deploy/run commands
Quote paths when you cite them, so the operator can follow.

## How to author an AWL workflow
AWL source (`.awl`) is the authoring truth. Do not substitute a language SDK project or generated compiler output. Follow checked examples and the current checker, not memory.
The method:
1. Read `ENVIRONMENT.md`, `AWL-AUTHORING.md`, and `AWL-REFERENCE.md`; inspect the closest checker-valid `.awl` example.
2. Author the workflow header, terminal outcomes, types, worker action requirements, children/subflows, and steps in one `.awl` document. World-touching work is a typed worker action; durable orchestration stays in AWL.
3. Use explicit outcomes for selection, explicit flow vocabulary for parallel/sequential work, and explicit bounds for every loop or backward route. Never invent syntax.
4. Check after every structural change. When this session carries the aion MCP tools, use `check_document` (pass the workspace path so schema imports resolve); otherwise run `aion awl check FILE.awl`. Treat every diagnostic as a source defect; optionally run `aion awl fmt`, inspect the diff, and check again.
5. Read `WORKERS.md` before promising an action implementation. The worker name is its task queue, a declaration does not create a worker, and shell workers are host-native.
6. Deploy the checked `.awl`. With the aion MCP tools: `save_document`, then `deploy_document` with the exact content_hash the save returned — deploy verifies the SAVED document against that hash, and both tools need the deploy grant (a `deploy_denied` refusal names what is missing; report it to the operator rather than retrying). Without them: the ops console or `aion deploy FILE.awl` / `aion run FILE.awl --input 'JSON'`. Then observe the durable run in the console. Never start or restart a server as part of authoring.

## Honesty rules
- Distinguish what you VERIFIED (a file you read, a command you ran in this session) from what you believe. Say "I have not verified this" when you have not.
- Never invent AWL syntax or worker capability. When unsure, read a checker-valid fixture or the parser/checker and check the source (`check_document` over MCP, or `aion awl check`).
- If the repository is not available to you, say so and answer only what you can stand behind.

## The operator's objective
"""

/// Payload of the `assistant_continue` signal.
type Continuation {
  /// The operator's next prompt, sent to the agent verbatim. Blank is a no-op
  /// nudge that does not spend a round.
  message: String,
  /// True finishes the session cleanly as `OperatorEnded`.
  end: Bool,
}

/// How the session ended. All three are CLEAN completions — the operator
/// ended it, or one of the two explicit budgets was spent. None is an error.
type Disposition = OperatorEnded | RoundCapExhausted | NudgeCapExhausted

/// What the `assistant_status` query reports: which stage the session is in,
/// and how many agent rounds it has run.
///
/// `round` is OPTIONAL where the Gleam original's is not, and the difference is
/// a language fact, not a choice. A loop's `counting` binding is readable only
/// AFTER the loop (`crates/aion-awl/src/emitter/loops.rs` removes it from the
/// body scope, and the checker refuses a body reference as "bound on some path
/// but not guaranteed"), and AWL has no arithmetic, so a round index cannot be
/// threaded through the loop either. Reporting `0` from inside the loop would
/// tell an operator that a session in round three is in round zero, which is
/// worse than saying nothing: absent means "this document cannot prove it
/// here", and every round number it DOES report is true.
type AssistantStatus { phase: String, round: Int? }

/// The recorded outcome of the server-run provision command. A bodied action's
/// result must be a record over the command outcome members (`exit_code`,
/// `stdout`, `stderr`); `exit_code` is always 0 here, because a non-zero exit
/// fails the dispatch instead of returning a result — and with no declared
/// retry policy the action gets exactly one attempt, so that failure reaches
/// the operator rather than any automatic re-run.
///
/// `stdout` is in the result because it IS the workspace path: the script
/// prints the resolved `<workspace_root>/<run_id>/repo` as its SOLE stdout
/// (every tool's chatter is redirected to stderr), so the recorded activity
/// result — and therefore durable history — carries the RESOLVED path. The
/// `{workspace_root}` placeholder lives only in the deployed contract's
/// command string and never appears in any recorded value.
type Provisioned { exit_code: Int, stdout: String }

/// The canonical agent-outcome record, as this document declares it: what
/// every dispatch of the `assistant` agent action returns. `text` is the whole accumulated output;
/// `final_message` is the last completed answer alone (both are honestly empty when the agent said
/// nothing, and equal for this harness's one-answer case); `stop_reason` is the canonical reason
/// (`end_turn` on every completed norn round — a non-completion fails the
/// dispatch instead of returning a record). The shape is demanded by the
/// checker on any `agent`-marked action; the NAME is this document's own.
/// `stop_reason` is available to route on; this session deliberately does not
/// — its routing behaviour predates the field and stands unchanged.
type Reply { text: String, final_message: String, stop_reason: String, session_id: String }

/// The one value the round loop threads, and the ONLY thing readable after
/// the loop: the prompt the next round will send, the last reply received,
/// whether the operator ended the session, and whether the wait loop gave up.
///
/// A binding made inside the nested wait loop is not guaranteed on every path
/// out of the outer loop, so the continuation itself cannot be read after the
/// loop — everything the outcomes need has to be folded into this value while
/// it is still in scope.
///
/// `stalled` is LOAD-BEARING, not bookkeeping. An AWL loop exits identically
/// whether `until` came true or `max` was reached — the lowering returns
/// `Ok(var)` on both paths (`crates/aion-awl/src/emitter/loops.rs:86-121`),
/// with no flag and no error. So a wait loop that exhausts its nudge budget
/// falls out holding a blank message, and WITHOUT this field the outer
/// `until` would be false and the session would dispatch another agent round
/// on an empty prompt, over and over, until the round budget was spent.
/// `stalled` is the only thing that makes the two exits distinguishable.
type Round { prompt: String, reply: String, ended: Bool, stalled: Bool }

/// The session's recorded result: how it ended, how many agent rounds ran, the
/// final agent reply, and the workspace path (which persists for inspection).
type Session { disposition: Disposition, rounds: Int, last_reply: String, workspace_path: String }

// The assistant's PRIVATE queue, named after the workflow itself. See the
// narration: `default` is the out-of-box workers' queue and this document
// never claims it, and the server refuses to load an embedded assistant whose
// declared queue is anything but its own workflow type.
worker assistant
  // The launch lives IN the document (#204): `aion worker agent` has no flag
  // for any of these settings, so without this section the verb the QUEUE MOVE
  // warn points operators at refuses the document as unlaunchable — which is
  // exactly what happened the night 0.14.0 shipped (#209). Four values are the
  // deleted flags' own, carried verbatim from the invocation AGENT-WORKERS.md
  // documented for THIS document before the migration: the norn harness, four
  // activities at once, reconnect climbing 1s toward a 30s cap with a 100-drop
  // budget, and the session pin that makes one workflow one continuous norn
  // conversation ({workflow_id}/{activity_type} expand at spawn).
  //
  // The ENVIRONMENT is not parity — it is stricter, and deliberately so.
  // Pre-migration the child inherited the worker's whole environment minus
  // `--norn-unset-env OPENAI_API_KEY`; `env_pass` had no predecessor flag, and
  // the child's environment is now CONSTRUCTED from this list and nothing
  // else: exactly PATH and HOME. That subsumes the old unset (a variable not
  // named here cannot reach the child at all). A box whose norn needs another
  // variable adds it to this list before deploying.
  //
  // `--account default` is the estate's own default alias (the example
  // worker's setup defaults NORN_ACCOUNT to the same value), and it rides
  // with the session pin because norn refuses `--resume-if-exists` without an
  // account. It is a baked value: an operator whose norn account alias is not
  // `default` changes that one token before deploying — binding a harness
  // ARGUMENT to a per-box or per-run value is the open half of board task #210,
  // which this landing did not close.
  //
  // `cwd` is DECLARED, and it is the one setting a shipped document could not
  // write until now. An agent's working directory decides which tree it reads
  // and which files it edits; undeclared, it was inherited from wherever the
  // worker process happened to be started, recorded in no document, argv or
  // log. But no absolute literal is right on more than one machine, and this
  // document ships inside the server binary — so it writes the placeholder
  // `{workspace_root}` and the launching worker expands it against THAT box's
  // own clones root (the startup banner's `workspace_root` field), then holds
  // the result to the absolute rule before anything is spawned. A box that
  // cannot resolve a root refuses the launch by name rather than inheriting a
  // directory nobody chose.
  //
  // What that gives, exactly: the agent starts in the box's workspace ROOT,
  // one level above the per-run workspaces the provision body materialises at
  // `<root>/<run_id>/repo`. The harness section is per-worker, so it cannot
  // name a per-run path — binding a setting to the run is the open half of
  // board task #210, and until it lands the opening prompt is what walks the
  // agent into its own session workspace.
  //
  // Still deliberately ABSENT: the old `--workspace-root`/`-C` confinement
  // pair. Those are `args`, expanded by the ADAPTER at spawn time, and this
  // landing gave placeholder expansion to path-valued SETTINGS only. So
  // tool-level confinement is not expressible here yet, and served as shipped
  // the agent's tools are NOT confined to the session workspace — the opening
  // prompt instructs conduct that holds either way instead of asserting
  // containment. An operator serving this on a real box may still add the pair
  // by hand with their own root (args "--workspace-root", "<root>", "-C",
  // "<root>/{workflow_id}/repo", …) before deploying.
  harness
    kind norn
    concurrency 4
    reconnect_initial_backoff 1s
    reconnect_max_backoff 30s
    reconnect_max_attempts 100
    env_pass "PATH", "HOME"
    args "--session-id", "{workflow_id}-{activity_type}", "--resume-if-exists", "--account", "default"
    cwd "{workspace_root}"

  /// Materialises the session workspace at `<workspace_root>/<run_id>/repo`
  /// and prints that resolved path as its SOLE stdout — the recorded activity
  /// result is where the workflow reads the workspace path from.
  /// SERVER-RUN, NO SANDBOX: this declared body executes on the server host as
  /// the server's own user, with env cleared to PATH only and cwd inherited
  /// from the server.
  ///
  /// THE SERVER STATES THE ROOT. `{workspace_root}` below is not a parameter —
  /// it is a literal placeholder the server expands at dispatch into its own
  /// aion home `clones/` directory (the startup banner's `workspace_root`
  /// field). If that root cannot resolve to an absolute directory that exists,
  /// the dispatch is refused terminally, by name; there is no fallback. The
  /// empty-`$1` guard in the script is defence-in-depth against a defective
  /// expansion, not a reachable path.
  ///
  /// The action gets exactly one attempt: no `retry` policy is declared, so
  /// a non-zero exit fails the dispatch and the failure reaches the operator
  /// — nothing re-runs the command automatically. The collision discipline is
  /// still load-bearing, for a different collision: a RE-RUN on a colliding
  /// workflow id (a workflow started again under an id whose directory
  /// survives) would find the earlier workspace in the way, and a clone into
  /// an existing directory fails. A stale `<workspace_root>/<run_id>` is
  /// therefore renamed aside to `<run_id>.superseded-<stamp>-<pid>` — never
  /// deleted — and the claim proceeds fresh. Empty `repo_path` is the scratch
  /// mode: the repo directory is created and `git init`ed instead of cloned.
  ///
  /// `run_id` must be the workflow's own id: it keys the directory AND must
  /// equal the id the agent-harness `--workspace-root` template expands to, or
  /// the agent works somewhere other than where it was provisioned.
  ///
  /// BYTE-IDENTICAL with `assistant_spike.awl` by requirement, not
  /// coincidence: the two documents share queue `assistant`, and retained
  /// contracts declaring DIFFERENT bodies for one action name refuse every
  /// dispatch of it terminally.
  action assistant_provision(run_id: String, repo_path: String) -> Provisioned
    run "sh -c 'set -eu; case \"$2\" in \"\"|.|..|*/*) echo provision: run_id must be a single path component >&2; exit 9;; esac; if [ -z \"$1\" ]; then echo provision: workspace_root must not be empty >&2; exit 9; fi; mkdir -p \"$1\" >&2; if [ -e \"$1/$2\" ]; then mv \"$1/$2\" \"$1/$2.superseded-$(date +%Y%m%dT%H%M%S)-$$\" >&2; fi; mkdir \"$1/$2\" >&2; if [ -z \"$3\" ]; then mkdir -p \"$1/$2/repo\" >&2; git init \"$1/$2/repo\" >&2; else git clone \"$3\" \"$1/$2/repo\" >&2; fi; printf \"%s\" \"$1/$2/repo\"' -- {workspace_root} {{run_id}} {{repo_path}}"

  /// One agent round under the canonical agent-outcome contract: ONE prompt
  /// string in, the `Reply` record out (`text` + `stop_reason` +
  /// `session_id`) — the shape
  /// every harness emits from its boundary. NO timeout, by design. The worker
  /// pins one norn session per action type per run, so repeated dispatches
  /// resume one conversation.
  ///
  /// The `agent` marker is what makes that routing readable from HERE. Before
  /// it, the worker binary carried the same fact as a hand-written list of
  /// action names, so this document could declare an agent round and the
  /// worker had no way to know it had.
  action assistant(prompt: String) -> Reply
    agent

step provision
  // Registered BEFORE the activity dispatch: provisioning clones a repository,
  // so it is the first stretch of a run long enough for an operator to ask
  // about, and zero rounds have run.
  answer assistant_status(phase: "provisioning", round: 0)
  assistant_provision(run_id: workflow.id, repo_path: repo_path) -> provisioned
  // The RECORDED activity result is the one source of the workspace path: the
  // body's sole stdout is the resolved `<workspace_root>/<run_id>/repo`, so
  // durable history carries the real path and never the placeholder. Nothing
  // in this document composes or re-derives it.
  provisioned.stdout -> workspace_path

step situate after provision
  // The one branch in the contract text: whether a repository was attached.
  // Same test the body's script makes on `$3` — empty means scratch.
  outcome scratch: if repo_path == "", route scratch_note
  outcome attached: else, route attached_note

step scratch_note
  // Both branch steps bind the SAME name and converge on `session`, so the
  // binding is guaranteed on every path in and the checker accepts the read.
  scratch_workspace + ground_truth_head + scratch_ground_truth -> situation
  route session

step attached_note
  attached_workspace_open + repo_path + attached_workspace_close + ground_truth_head + attached_ground_truth -> situation
  route session

step session
  // The round-one prompt: the full working contract with the workspace path
  // and branch fragment spliced in, then the operator's objective under the
  // heading `contract_tail` ends with. The seams are exact — see the const
  // block's note on verbatim raw strings.
  contract_head + workspace_path + workspace_confinement + situation + contract_tail + objective -> opening_prompt
  // The outer loop is one agent round plus the operator wait. There is no
  // round budget: the session runs until the operator ends it or a wait
  // stalls out (both `max` clauses below are language formalities set beyond
  // reach). The body runs at least once, so a session always gets one round.
  //
  // The inner loop is the operator wait, and it is where the nudge tolerance
  // lives: it re-parks on `assistant_continue` until the operator either ends
  // the session or sends a non-blank message, absorbing blank nudges without
  // spending an agent round. AWL demands a bound on it, so the tolerance is
  // finite where the Gleam version's was not.
  //
  // `stalled` folds the inner loop's give-up into the threaded value, and the
  // outer `until` tests it. Both halves are required: an AWL loop cannot
  // report WHY it exited, so without this the exhausted wait would fall
  // through and the next iteration would dispatch an agent round on an empty
  // prompt. See `Round`.
  loop round = Round(prompt: opening_prompt, reply: "", ended: false, stalled: false) counting rounds
    // Re-registered every pass, which is what makes the answer LIVE: the value
    // is computed here, from the bindings in scope here, and replay re-runs the
    // statement rather than evaluating anything at a yield point.
    answer assistant_status(phase: "working")
    assistant(prompt: round.prompt) -> reply
    // The long park. A real session has sat on this signal for 28 days; this is
    // the answer that says so.
    answer assistant_status(phase: "awaiting_operator")
    loop nudge = Continuation(message: "", end: false)
      wait assistant_continue -> nudge
      until nudge.end or nudge.message != ""
      // The session has no operational cap (the operator's ruling: it runs
      // until ended or stalled). AWL requires a literal bound on every loop,
      // so this is the language formality set beyond reach, not a budget.
      max 1000000
    Round(prompt: nudge.message, reply: reply.text, ended: nudge.end, stalled: not nudge.end and nudge.message == "") -> round
    until round.ended or round.stalled
    // Same formality as the inner bound: no round budget exists — the session
    // ends when the operator ends it or a wait stalls out.
    max 1000000
  // Past the loop the counter IS readable, so the final answer carries the
  // real round count — the one point in the run where both fields are provable.
  answer assistant_status(phase: "settled", round: rounds)

  // Three ways out, all read off the threaded value — see `Round` for why the
  // continuation itself is not readable here. Ordered deliberately: an
  // operator who ends with a blank message sets BOTH `ended` and the blank
  // condition, and their intent wins.
  outcome operator_ended: if round.ended,
    route ended(disposition: OperatorEnded, rounds: rounds, last_reply: round.reply,
      workspace_path: workspace_path)
  // The wait loop spent its nudge budget without the operator ever deciding,
  // so the session ends here rather than dispatching another round.
  outcome nudges_spent: if round.stalled,
    route stalled(disposition: NudgeCapExhausted, rounds: rounds, last_reply: round.reply,
      workspace_path: workspace_path)
  // Formally required exhaustion arm: AWL demands a route for a loop reaching
  // its `max`, but with the bound set beyond reach this is unreachable in
  // practice. Kept as an OUTCOME, never an error.
  outcome rounds_spent: else,
    route capped(disposition: RoundCapExhausted, rounds: rounds, last_reply: round.reply,
      workspace_path: workspace_path)