Skip to content

fix(tests): make the sonnet-workflows tutorial CI-viable - #881

Merged
arantespp merged 1 commit into
mainfrom
claude/soat-graphed-system-z169oo-tutorials-determinism
Aug 8, 2026
Merged

fix(tests): make the sonnet-workflows tutorial CI-viable#881
arantespp merged 1 commit into
mainfrom
claude/soat-graphed-system-z169oo-tutorials-determinism

Conversation

@arantespp

Copy link
Copy Markdown
Member

Summary

orchestrate-a-sonnet-with-workflows sits in .tutorialsignore on the grounds that its on_enter agent dispatch is inherently non-deterministic, "same class as review-panel-discussion." That diagnosis is wrong on both counts, and two of the real causes are outright bugs rather than flakiness.

It is not the same class. review-panel-discussion is ignored because qwen2.5:0.5b cannot reliably emit the structured JSON its agent nodes declare via output_schema — a model-capability problem. This tutorial declares no output_schema anywhere, and every on_complete is {"when": true}, so the card advances on any completion regardless of content. Nothing here depends on model quality — only on completion.

This PR fixes the causes. It deliberately does not remove the .tutorialsignore entry; see Why the ignore entry stays below.

What was actually broken

1. The AI provider pinned llama3.2, which the CI stack never pulls. docker-compose.tutorials.yml pulls qwen2.5:0.5b only, so every generation in this tutorial would fail outright — a deterministic failure that the "flaky LLM" framing hid entirely. Now qwen2.5:0.5b, per .claude/rules/tutorials.md.

2. The # → 400 annotation was placed where it silently binds to the wrong command. It sat on its own line after the command. In tutorials-tests.sh, a command line with balanced quotes is flushed the moment it is read, so CURRENT_CMD is already empty and the hint becomes NEXT_ANNOTATION — it bound to update-task. Net effect: the expected guard rejection was unannotated and failed the run, while update-task was wrongly expected to fail. Moved before the command.

3. The tutorials stack never received MAX_COMPLETION_TOKENS. Both stacks proxy Ollama through the same ollamaToolChoiceProxy.mjs, which implements the cap generically ("Unset = no cap") and notes that agents carry no max_tokens of their own. The smoke stack sets 256; tutorials set nothing. Uncapped output is what made the smoke job swing 219s → 406s between identical runs. Capping it here bounds every tutorial's wall-clock, not just this one.

4. revise pointed at create_text, re-running all five generations on the rework pass — ten per tutorial run. It now targets stanza_4 for a fresh closing couplet: one generation instead of five, with the backward-move lesson unchanged. The prose makes the modelling choice explicit, since re-entering at the step that needs redoing is the actual point.

5. No state declared on_failure, so a generation error parked the card with no route out — a hang rather than a failure. Every dispatch state now falls back to a new abandon_to_review transition.

6. Two hand-rolled for … sleep 2 poll loops are replaced with # → retry N over a jq -e assertion — what that annotation documents itself as replacing.

It also brings the file up to authoring rules it predates: curl tabs used SOAT_URL instead of SOAT_BASE_URL, there were no SDK tabs at all (against the three-tab rule), and the board query's curl tab read .[] where the endpoint returns {data: [...]}.

Root cause of #2 — a docs bug that generates this mistake

.claude/rules/tests.md claimed "a trailing inline annotation applies to the command it sits on". That is false. The extractor only matches # at the start of a line, so an inline # → … reaches the shell as an ordinary comment and the annotation is lost. The same passage said an annotation on its own line applies to the next command — true, but it reads as though placing one after a command works.

Both are corrected, with all three placements shown explicitly, and the stale comment in tutorials-tests.sh claiming inline annotations are "preserved as a synthetic marker line" now describes what the awk actually does. Behavior is pinned by tests/harness/tutorialsRetryAnnotation.test.mjs, where every case places the annotation on the preceding line.

This matters beyond one file: #878 inherited the same bug from this documentation (a trailing # → expect-fail). Fixing that is a one-line follow-up on that branch.

Why the ignore entry stays

Un-ignoring makes a flake block every PR, and the Docker daemon is unavailable in the environment this was written in, so the tutorial has not been executed end to end. Removing the entry belongs in a follow-up with green runs behind it — TUTORIAL_ID=orchestrate-a-sonnet-with-workflows docker compose -f tests/docker-compose.tutorials.yml up …, a few times.

Fix 3 is worth landing on its own merits regardless, since it bounds runtime for every tutorial in the suite.

Verification

  • node scripts/docs-lint.mjs — OK, 460 files, no violations
  • node --test tests/harness/tutorialsRetryAnnotation.test.mjs — 6/6 pass (the runner change is comment-only, but this pins the annotation contract the docs now describe)
  • pnpm --filter @soat/website build — exit 0, no broken links or anchors
  • docker-compose.tutorials.yml parses; proxy env resolves to {PORT, OLLAMA_UPSTREAM_URL, TOOL_CHOICE_TOOLS, MAX_COMPLETION_TOKENS: '256'}
  • Static checks on the extracted CLI blocks: 10 blocks, single-quote parity even in every one, both multi-line JSON payloads parse after shell interpolation, all 10 <Tabs> groups now carry cli/sdk/curl, zero SOAT_URL, zero llama3.2
  • on_failure verified against workflowsValidation.ts:197 — it references a transition name, not a state, which is why abandon_to_review had to be added rather than pointing at review directly

Not verified: the tutorial has not run against a live server (no Docker daemon available). That is exactly why the ignore entry stays in this PR.

Related: #878

🤖 Generated with Claude Code

https://claude.ai/code/session_01AjeWpxDqRDtkSv1mCDspf9


Generated by Claude Code

`orchestrate-a-sonnet-with-workflows` sits in `.tutorialsignore` on the
grounds that its `on_enter` agent dispatch is inherently
non-deterministic, "same class as review-panel-discussion". That
diagnosis is wrong on both counts, and two of the real causes are
outright bugs rather than flakiness.

It is not the same class. review-panel-discussion is ignored because
`qwen2.5:0.5b` cannot reliably emit the structured JSON its agent nodes
declare via `output_schema`. This tutorial declares no `output_schema`
anywhere, and every `on_complete` is `{"when": true}` — the card advances
on any completion regardless of content. Nothing here depends on model
quality; only on completion.

Fixed, in order of impact:

1. The AI provider pinned `llama3.2`, which the CI stack never pulls (it
   pulls `qwen2.5:0.5b` only). Every generation would fail outright —
   deterministic failure, not a flake. Now `qwen2.5:0.5b`.

2. The `# → 400` annotation sat on its own line *after* the command it
   was meant for. A command line with balanced quotes is flushed the
   moment it is read, so the annotation bound to the *next* command:
   the expected guard rejection went unannotated (failing the run) and
   `update-task` was wrongly expected to fail. Moved before the command.

3. The tutorials stack never received `MAX_COMPLETION_TOKENS`. Both
   stacks proxy Ollama through the same `ollamaToolChoiceProxy.mjs`,
   which implements the cap generically ("Unset = no cap") and notes
   that agents carry no `max_tokens` of their own; the smoke stack sets
   256, tutorials set nothing. Uncapped output is what made smoke swing
   219s -> 406s between identical runs. Now capped for tutorials too,
   which bounds every tutorial's wall-clock, not just this one.

4. `revise` pointed at `create_text`, re-running all five generations on
   the rework pass — ten per tutorial run. It now targets `stanza_4` for
   a fresh closing couplet: one generation instead of five, and the
   backward-move lesson is unchanged. The prose makes the modelling
   choice explicit, since re-entering at the step that needs redoing is
   the point.

5. No state declared `on_failure`, so a generation error parked the card
   with no route out — a hang rather than a failure. Every dispatch state
   now falls back to a new `abandon_to_review` transition.

6. The two hand-rolled `for … sleep 2` poll loops are replaced with
   `# → retry N` over a `jq -e` assertion, which is what that annotation
   documents itself as replacing.

Also brings the file up to the authoring rules it predates: curl tabs
used `SOAT_URL` instead of `SOAT_BASE_URL`, there were no SDK tabs at
all, and the board query's curl tab read `.[]` where the endpoint
returns `{data: [...]}`.

The tutorial stays in `.tutorialsignore`. Un-ignoring makes a flake block
every PR, so that belongs in a follow-up with green runs behind it.

Root cause of (2): `.claude/rules/tests.md` claimed a trailing inline
annotation applies to the command it sits on, and that an annotation on
its own line applies to the next command. The first is false — the
extractor only matches `#` at line start, so an inline annotation reaches
the shell as a plain comment and is lost. The second is true but reads as
though placing it after a command works. Both are corrected, with the
three placements shown explicitly, and the stale comment in
tutorials-tests.sh that claimed inline annotations are preserved as a
synthetic marker line is fixed to describe what the awk actually does.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjeWpxDqRDtkSv1mCDspf9
arantespp pushed a commit that referenced this pull request Aug 8, 2026
The guard-rejection step used a trailing inline `# → expect-fail`. The
tutorial runner only matches an annotation at the start of a line, so an
inline one reaches the shell as an ordinary comment and is lost — the
step would then fail the run on its (expected) non-zero exit.

Moved onto its own line before the command, which is the only placement
`tests/tutorials-tests.sh` honors and the one every case in
tests/harness/tutorialsRetryAnnotation.test.mjs uses.

The stale guidance that produced this is corrected in #881.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjeWpxDqRDtkSv1mCDspf9
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown

Deploy Outputs

Package Stack Output Key Output Value
@soat/website SoatWebsite-claude-soat-graphed-system-z169oo-tutorials-determinism BucketWebsiteURL http://soatwebsite-claude-soat-graphed-syste-staticbucket-1kp3zeh0p9wf.s3-website-us-east-1.amazonaws.com

@arantespp
arantespp merged commit dd99e89 into main Aug 8, 2026
11 checks passed
@arantespp
arantespp deleted the claude/soat-graphed-system-z169oo-tutorials-determinism branch August 8, 2026 01:58
arantespp pushed a commit that referenced this pull request Aug 8, 2026
The guard-rejection step used a trailing inline `# → expect-fail`. The
tutorial runner only matches an annotation at the start of a line, so an
inline one reaches the shell as an ordinary comment and is lost — the
step would then fail the run on its (expected) non-zero exit.

Moved onto its own line before the command, which is the only placement
`tests/tutorials-tests.sh` honors and the one every case in
tests/harness/tutorialsRetryAnnotation.test.mjs uses.

The stale guidance that produced this is corrected in #881.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjeWpxDqRDtkSv1mCDspf9
arantespp added a commit that referenced this pull request Aug 8, 2026
* docs(website): add month-end close capstone tutorial

Adds "Close the Monthly Books", a capstone tutorial that composes an
orchestration, a workflow, a trigger, and a human approval into one
governed process. The existing graph tutorials each teach a single
mechanism in a toy domain; none of them shows the layers working
together, and none uses a domain where the branch condition has a right
answer.

A financial close does. Every routing decision in the graph is JSON
Logic arithmetic over ledger figures, so the same books always take the
same path, and the one agent node writes a variance memo with no
output_schema and nothing parsing its text. The model gets the last word
on wording, never on control flow.

What it demonstrates:

- Three reconciliations as parallel start nodes converging on an
  activation_group join with activation_condition "all"
- A condition node routing on a computed variance vs. tolerance, with
  the untaken branch recorded as skipped
- validate-orchestration as a pre-flight check
- A workflow with a backward transition (request_rework), which is the
  concrete reason a period is not a DAG
- A transition carrying both gates: a deterministic guard first, then
  requires_approval, so a human is only asked about something the
  arithmetic already accepted
- The same approvals queue serving a workflow transition rather than an
  orchestration node
- Schedule and manual triggers, and the task transition history as an
  audit trail

Also documents the input/state namespace rule the validator enforces:
run input reads as {"var": "input.x"}, while a bare {"var": "x"} reads a
state key an upstream node wrote and is never satisfied from run input.

Wires the page into sidebars.ts, adds a capstone routing line to the
tutorials index, and cross-links it from the orchestrations, workflows,
and approvals module pages.

The tutorial is deliberately left out of tests/.tutorialsignore so the
tutorials-test job validates it end to end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjeWpxDqRDtkSv1mCDspf9

* fix(website): place the expect-fail annotation before its command

The guard-rejection step used a trailing inline `# → expect-fail`. The
tutorial runner only matches an annotation at the start of a line, so an
inline one reaches the shell as an ordinary comment and is lost — the
step would then fail the run on its (expected) non-zero exit.

Moved onto its own line before the command, which is the only placement
`tests/tutorials-tests.sh` honors and the one every case in
tests/harness/tutorialsRetryAnnotation.test.mjs uses.

The stale guidance that produced this is corrected in #881.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjeWpxDqRDtkSv1mCDspf9

* fix(website): correct async runs and the guard/approval ordering

The first real CI run of this tutorial (tutorials-test on #878) failed and
exposed three wrong assumptions. All three were mine; the annotation fix
in the previous commit is what let the run get far enough to reveal them.

1. `start-orchestration-run` does not run the graph synchronously. It
   enqueues and returns `status: "queued"` with an empty `state`, and a
   worker drives it. Every `.state.*` read in steps 5 and 6 came back
   null, so `EXCEPTION_VARIANCE` was the literal string "null" and landed
   in the task payload as `total_variance: null`. Both steps now capture
   the run id, poll `get-orchestration-run` with `# → retry N` until the
   run is terminal, and read results from there. The SDK tabs gain a
   `waitForRun` helper and the curl tabs an `until` loop.

   (`orchestrate-a-sonnet` documents a synchronous-looking `succeeded`
   response for the same call. It passes CI only because it prints that
   response without asserting on it — worth a separate look.)

2. A `guard` and `requires_approval` on the same transition are not
   evaluated together. Firing an approval-gated transition parks the
   approval **first** and re-evaluates the guard when the item resolves.
   Step 10 claimed the opposite — that the guard refuses before any human
   is asked — and asserted a failure that does not happen: the CI run
   returned 200 with `pending_transition: "close_period"`.

   The step no longer fires `close_period` early, and the prose now
   describes the real ordering plus what it buys: a sign-off cannot be
   collected while the books balance and then applied after they stop
   balancing, because the deterministic check runs last.

3. While an approval is pending, no other transition may fire. The old
   step 10 fired `close_period` and then `request_rework`, which would
   have been blocked by the pending gate. The rework move now happens
   before any sign-off is requested.

Also documents both findings in How It Works, since the async-run and
gate-ordering rules are the two things a reader is most likely to get
wrong when building on this.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjeWpxDqRDtkSv1mCDspf9

* fix(website): update the approvals deep link to the renamed step 11

Step 11 was retitled when the guard/approval ordering was corrected, so
the anchor in approvals.md no longer resolved and the Docusaurus build
failed on it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjeWpxDqRDtkSv1mCDspf9

---------

Co-authored-by: Claude <noreply@anthropic.com>
arantespp added a commit that referenced this pull request Aug 8, 2026
…eview panel (#890)

Two changes to tests/.tutorialsignore.

**orchestrate-a-sonnet-with-workflows** — the comment was wrong, and it was
wrong in a way that discouraged anyone from retrying the tutorial. It
claimed the tutorial was inherently nondeterministic because of `on_enter`
agent dispatch, "same class as review-panel-discussion". Neither half held:
the tutorial declares no `output_schema` anywhere and every `on_complete`
is `{"when": true}`, so the card advances on any completion regardless of
what the model produced. Nothing in it depended on model quality.

The real causes were mundane and are fixed on main (#881): the provider
pinned `llama3.2`, which this stack never pulls; the `# → 400` annotation
sat after its command so it bound to the next one; the stack had no
`MAX_COMPLETION_TOKENS`, leaving output unbounded; `revise` re-entered
`create_text` for ten generations per run instead of six; and no state
declared `on_failure`, so a failed generation stalled the card.

The entry stays because what is missing now is evidence, not a fix — the
tutorial has not been executed end to end since those landed. The comment
says so and names the command that would settle it.

**review-panel-discussion** — removed from the ignore list, so CI runs it
again. Its documented cause is unchanged and unaddressed: both agent nodes
declare an `output_schema` and `buildStructuredOutput` validates the model's
JSON with ajv, so a 0.5b model that emits a non-conforming object fails the
generation and the run. This re-enables it to find out whether that still
reproduces, rather than on the strength of a fix.

Static checks on the tutorial pass — 11 CLI blocks with even single-quote
parity, both JSON payloads valid, all three tabs on every block, the
correct `qwen2.5:0.5b` model, no `SOAT_URL`, no annotations — so a
structural failure of the kind that broke the workflows tutorial is ruled
out. The runtime question is not.

If it flakes, the revert is re-adding one line.


Claude-Session: https://claude.ai/code/session_01AjeWpxDqRDtkSv1mCDspf9

Co-authored-by: Claude <noreply@anthropic.com>
arantespp pushed a commit that referenced this pull request Aug 8, 2026
The first CI run of the re-enabled tutorial timed out rather than failing:
the card reached `review` normally, but 90 attempts ran out before it got
there. Server logs show the whole chain completing.

  10:07:11  start
  10:07:44  to_stanza_1  (+32s)
  10:08:55  to_stanza_2  (+72s)
  10:09:32  to_stanza_3  (+36s)
  10:09:54  to_stanza_4  (+22s)
  10:10:10  to_review    (+16s)
  ---------------------------------
  forward chain: 179s

So the workflow logic and every #881 fix are sound; only the budget was
wrong. It was wrong because I sized it by extrapolating the ~9s/completion
implied by the smoke-suite profiling in tests.md. That number does not
transfer: these prompts carry the accumulated poem forward, so input grows
each hop, and per-generation cost here ranged 16-72s. A poll attempt also
costs its 1s sleep plus a CLI round trip, so 90 attempts bought roughly
135s of wall-clock against a 179s chain.

Forward chain 90 -> 240 attempts (~2x the observed total, so ordinary
variance does not fail the run) and the revise pass 40 -> 120. The SDK
tab's `waitForState` loop is raised to match. A budget is a ceiling, not a
cost: the loop exits as soon as the card lands.

The measured timings are now recorded in the step's prose, so the next
person sizing this has data instead of an extrapolation.

Worth weighing separately: this tutorial contributes about three minutes
of Ollama time to an already Ollama-bound job. Cutting the chain from four
stanzas to two would roughly halve that, at the cost of the Shakespearean
structure the tutorial is built around.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjeWpxDqRDtkSv1mCDspf9
arantespp added a commit that referenced this pull request Aug 8, 2026
* test(tutorials): re-enable orchestrate-a-sonnet-with-workflows

Removes the last temporary ignore entry. Its causes were fixed in #881 —
the unpulled `llama3.2` model, the annotation bound to the wrong command,
the missing `MAX_COMPLETION_TOKENS`, `revise` re-entering `create_text`
for ten generations instead of six, and no `on_failure` on the dispatch
states. What the entry was waiting on was a green run, and CI on this PR
is that run.

Verified statically against the merged file first, since the annotation
placement is the bug class that broke it before:

- all three annotations (`# → retry 90`, `# → retry 40`, `# → 400`) sit on
  their own line immediately before the command they apply to, which is
  the only placement tests/tutorials-tests.sh honors;
- `STATES` and `TRANSITIONS` both parse as JSON once `'"$AGENT_ID"'` is
  substituted;
- single-quote parity is even in all 10 CLI blocks, so no comment
  desyncs the runner's line accumulator;
- all 10 tab groups carry cli/sdk/curl; zero `SOAT_URL`; zero `llama3.2`;
- `on_failure` present on every dispatch state; `revise` targets
  `stanza_4`, so the rework pass costs one generation rather than five.

The ignore list is now three entries, all of them permanent: `index` (a
landing page with no commands) and two tutorials that need real
third-party or cloud credentials CI should not hold.

Timing is the remaining risk, not correctness. The forward chain is five
sequential generations against `qwen2.5:0.5b` on a CPU-only runner,
capped at 256 output tokens, with a 90-attempt (~90s) retry budget; the
revise pass is one generation with 40. If it times out rather than
failing, raising those budgets is the fix, not re-ignoring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjeWpxDqRDtkSv1mCDspf9

* fix(website): size the sonnet-workflow retry budgets from measurement

The first CI run of the re-enabled tutorial timed out rather than failing:
the card reached `review` normally, but 90 attempts ran out before it got
there. Server logs show the whole chain completing.

  10:07:11  start
  10:07:44  to_stanza_1  (+32s)
  10:08:55  to_stanza_2  (+72s)
  10:09:32  to_stanza_3  (+36s)
  10:09:54  to_stanza_4  (+22s)
  10:10:10  to_review    (+16s)
  ---------------------------------
  forward chain: 179s

So the workflow logic and every #881 fix are sound; only the budget was
wrong. It was wrong because I sized it by extrapolating the ~9s/completion
implied by the smoke-suite profiling in tests.md. That number does not
transfer: these prompts carry the accumulated poem forward, so input grows
each hop, and per-generation cost here ranged 16-72s. A poll attempt also
costs its 1s sleep plus a CLI round trip, so 90 attempts bought roughly
135s of wall-clock against a 179s chain.

Forward chain 90 -> 240 attempts (~2x the observed total, so ordinary
variance does not fail the run) and the revise pass 40 -> 120. The SDK
tab's `waitForState` loop is raised to match. A budget is a ceiling, not a
cost: the loop exits as soon as the card lands.

The measured timings are now recorded in the step's prose, so the next
person sizing this has data instead of an extrapolation.

Worth weighing separately: this tutorial contributes about three minutes
of Ollama time to an already Ollama-bound job. Cutting the chain from four
stanzas to two would roughly halve that, at the cost of the Shakespearean
structure the tutorial is built around.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjeWpxDqRDtkSv1mCDspf9

---------

Co-authored-by: Claude <noreply@anthropic.com>
@arantespp arantespp mentioned this pull request Aug 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants