fix(tests): make the sonnet-workflows tutorial CI-viable - #881
Merged
arantespp merged 1 commit intoAug 8, 2026
Merged
Conversation
`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
Deploy Outputs
|
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>
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
orchestrate-a-sonnet-with-workflowssits in.tutorialsignoreon the grounds that itson_enteragent 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-discussionis ignored becauseqwen2.5:0.5bcannot reliably emit the structured JSON its agent nodes declare viaoutput_schema— a model-capability problem. This tutorial declares nooutput_schemaanywhere, and everyon_completeis{"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
.tutorialsignoreentry; 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.ymlpullsqwen2.5:0.5bonly, so every generation in this tutorial would fail outright — a deterministic failure that the "flaky LLM" framing hid entirely. Nowqwen2.5:0.5b, per.claude/rules/tutorials.md.2. The
# → 400annotation was placed where it silently binds to the wrong command. It sat on its own line after the command. Intutorials-tests.sh, a command line with balanced quotes is flushed the moment it is read, soCURRENT_CMDis already empty and the hint becomesNEXT_ANNOTATION— it bound toupdate-task. Net effect: the expected guard rejection was unannotated and failed the run, whileupdate-taskwas wrongly expected to fail. Moved before the command.3. The tutorials stack never received
MAX_COMPLETION_TOKENS. Both stacks proxy Ollama through the sameollamaToolChoiceProxy.mjs, which implements the cap generically ("Unset = no cap") and notes that agents carry nomax_tokensof their own. The smoke stack sets256; 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.
revisepointed atcreate_text, re-running all five generations on the rework pass — ten per tutorial run. It now targetsstanza_4for 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 newabandon_to_reviewtransition.6. Two hand-rolled
for … sleep 2poll loops are replaced with# → retry Nover ajq -eassertion — what that annotation documents itself as replacing.It also brings the file up to authoring rules it predates: curl tabs used
SOAT_URLinstead ofSOAT_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.mdclaimed "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.shclaiming inline annotations are "preserved as a synthetic marker line" now describes what theawkactually does. Behavior is pinned bytests/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 violationsnode --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 anchorsdocker-compose.tutorials.ymlparses; proxy env resolves to{PORT, OLLAMA_UPSTREAM_URL, TOOL_CHOICE_TOOLS, MAX_COMPLETION_TOKENS: '256'}<Tabs>groups now carry cli/sdk/curl, zeroSOAT_URL, zerollama3.2on_failureverified againstworkflowsValidation.ts:197— it references a transition name, not a state, which is whyabandon_to_reviewhad to be added rather than pointing atreviewdirectlyNot 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