Commit 61802dc
Rectify: Surface LoadRecipeResult Errors and Fail-Closed on Invalid Content (#3995)
## Summary
`open_kitchen` silently returns `content: ""` with `success: true` when
post-prune validation detects dangling routes or route-consistency
errors. The architectural weakness is threefold:
1. **`load_and_validate()` computes structural errors but discards
them** — `LoadRecipeResult` has no `errors` field, so the reason for
`valid=False` is lost at the TypedDict boundary.
2. **`open_kitchen` stamps `success: true` unconditionally** — it never
checks `result["valid"]` or `result["content"]` before returning.
3. **`_prune_skipped_steps` produces unrepairable dangling routes
silently** — when a pruned step has no computable redirect (no
`on_success`, no `when=None` catch-all in `on_result`), the route-repair
loop is a no-op, and the only signal is a list of strings returned from
`_validate_no_dangling_routes` that gets appended to a local variable
and then discarded.
Part A addresses the core data-loss path: surfacing structural errors
through `LoadRecipeResult`, making `open_kitchen` fail-closed on invalid
content, and adding tests that catch this class of bug. Part B will
address the recipe YAML fixes (adding `when=None` catch-all to
`resolve_review`) and broader vacuous-assertion cleanup.
## Problem
When the orchestrator backend is Codex,
`open_kitchen(name="implementation")` returns `success: true`, `valid:
false`, and `content: ""` — the **entire recipe body is silently
withheld**. The orchestrator receives no explanation for the empty
content, has no prompt instruction for this state, and is structurally
blocked from every recovery path (`recipe_read_guard.py` PreToolUse hook
+ sous-chef raw-read prohibition + `load_recipe` returning the same
empty result).
In run `impl-20260610-095047-486925` (TalonT-Org/the-token-reserve#147,
PR TalonT-Org/the-token-reserve#189, AutoSkillit 0.10.745, 2026-06-10
09:49–11:31), the Codex orchestrator **improvised the entire 60-minute
pipeline from the Mermaid `diagram` field, the ingredients table, and
the sous-chef prompt** — executing steps the composition had pruned as
backend-incompatible (e.g. `implement` → `implement-worktree-no-merge`,
declared `requires backend ['claude-code']`), with no step routing, no
`capture:` contracts, no retry counts, and zero `record_pipeline_step`
calls. The PR happened to merge green, but the run cannot be trusted to
have followed the composed plan.
**Reproduced at repo HEAD `18f410893` (0.10.748).** This is the sixth
manifestation of the step-pruning/content-divergence family.
## Root cause chain
1. **Guard introduction (2026-06-09):** `207deda91` (#3960) introduced
the `backend_supports_git_write` ingredient and added `skip_when_false:
inputs.backend_supports_git_write` to 7 steps in `implementation.yaml`
(and 7 in `remediation.yaml`) including `resolve_review` — without
giving `resolve_review` a `when=None` catch-all route. The bug became
reachable <24h before the failing run.
2. **Codex capability override:** `_backend_capability_overrides()`
(`src/autoskillit/server/tools/_auto_overrides.py:19-28`) maps Codex's
`git_metadata_writable=False` to `backend_supports_git_write="false"`,
injected into session overrides at
`src/autoskillit/server/tools/tools_kitchen.py:540`, promoted over
user-supplied overrides via `_promote_capability_keys` (line 542), and
passed to `load_and_validate` (lines 627-635).
3. **Unrepairable route:** `_prune_skipped_steps()`
(`src/autoskillit/recipe/_recipe_composition.py:294-415` (redirect
derivation 352-364)) prunes `resolve_review` but derives `redirect =
None` — the step has no `on_success` and all four of its `on_result`
conditions carry `when` clauses (the repair logic deliberately never
derives a redirect from `on_failure`/`on_exhausted`)
(`src/autoskillit/recipes/implementation.yaml:1089-1097`). The surviving
`enrich_diff_context` (`on_success: resolve_review`, `on_failure:
resolve_review`, `implementation.yaml:1064-1065`) is left dangling. The
code comment acknowledges this path: "redirect stays None and
`_validate_no_dangling_routes` catches dangling refs."
4. **Silent content wipe:** `_validate_no_dangling_routes` returns
`["Step 'enrich_diff_context' routes to unknown step 'resolve_review'"]`
and `load_and_validate()` sets `raw = ""`
(`src/autoskillit/recipe/_api.py:415-418`). The `errors` list feeds only
`compute_recipe_validity()`; **`LoadRecipeResult` has no `errors`
field**, so the reason never reaches the caller.
5. **No fail signal, no recovery:** `open_kitchen` returns `success:
true, kitchen: "open"` without checking `valid`/`content`
(`tools_kitchen.py:690-692`; the deferred-recall branch at 605-607 is
equally unchecked). Neither sous-chef SKILL.md nor any orchestrator
prompt covers `content: ""`. The #3736/#3947 defenses (raw-read
prohibition, `recipe_read_guard.py`, compaction disable) eliminate
self-recovery, converting the state into a trap.
## Reproduction (repo HEAD `18f410893`, 0.10.748)
```python
load_and_validate("implementation", project_dir=Path("/home/talon/projects/token-reserve"),
backend_name="codex", lister=DefaultSkillResolver(),
ingredient_overrides={"backend_supports_git_write": "false",
"post_run_diagnostics": "false"})
# → valid: False, content_len: 0, 13 error-severity backend-incompatible-skill suggestions,
# exactly one dangling-route error (instrumented): enrich_diff_context → resolve_review
# same call with backend_name="claude-code" → valid: True, content_len: 86,942
```
Claude Code is unaffected because `git_metadata_writable=True` produces
no pruning of these steps — full content is delivered. This is **not**
Codex-side truncation: the run's rollout log records the full
`open_kitchen` results (90,635 and 86,672 bytes) with zero truncation
markers; `tool_output_token_limit=50000` and compaction-disable were
correctly written to `config.toml`.
## Remediation targets
1. **Surface the wipe reason:** add `errors: list[str]` to
`LoadRecipeResult` and populate it whenever `raw` is cleared
(`_api.py:415-426`).
2. **Fail loudly in `open_kitchen`:** when `valid is False` and `content
== ""`, return a failure envelope (`success: false` + the dangling-route
errors) — never `success: true` with no recipe.
3. **Orchestrator halt instruction:** sous-chef/orchestrator-prompt rule
— if `open_kitchen` returns empty `content`, halt and escalate; never
execute from the diagram.
4. **Fix the recipe:** give `resolve_review` a computable redirect
(`when=None` catch-all) or guard `enrich_diff_context`'s routes
consistently so the pruned subgraph is removed atomically; audit the
other `backend_supports_git_write`-guarded steps — 11 in
`implementation.yaml` at HEAD, added across `207deda91`, `884e1f2ae`,
`d88bda83a` — and the other bundled recipes (`remediation.yaml` gained 7
guards in the same commit) for the same exposure.
5. **Close the test escape hatch:** replace the `continue` at
`tests/recipe/test_hidden_ingredients.py:966-975` with an assertion (and
ideally a semantic validation rule) that every prunable step has a
computable redirect; add a codex-composition test asserting `valid is
True` AND non-empty `content` AND all surviving steps present for each
bundled recipe.
6. **Secondary:** bundled skills are invisible to Codex sessions (no
`--plugin-dir` equivalent;
`src/autoskillit/workspace/session_skills.py:319` excludes `BUNDLED`),
so the orchestrator could not map a post-hoc "run analyze health"
request to `/autoskillit:analyze-pipeline-health`; consider listing
invocable bundled skill commands in the Codex orchestrator prompt or
`open_kitchen` result.
Closes #3992
## Implementation Plan
Plan file:
`/home/talon/projects/autoskillit-runs/remediation-20260610-175022-808728/.autoskillit/temp/rectify/rectify_silent_recipe_content_wipe_part_a_2026-06-10_180512.md`
🤖 Generated with [Claude Code](https://claude.com/claude-code) via
AutoSkillit
<!-- autoskillit:pipeline-signature
steps=prepare_pr,run_arch_lenses,compose_pr,annotate_pr_diff,review_pr
-->
## Token Usage Summary
| Step | Model | count | uncached | output | cache_read | peak_ctx |
turns | cache_write | time |
|------|-------|-------|----------|--------|------------|----------|-------|-------------|------|
| rectify* | opus[1m] | 1 | 4.9k | 23.2k | 1.3M | 97.0k | 43 | 82.9k |
21m 11s |
| review_approach* | opus[1m] | 1 | 4.5k | 6.1k | 260.0k | 49.0k | 14 |
35.4k | 5m 23s |
| dry_walkthrough* | opus | 1 | 41 | 9.6k | 867.8k | 89.3k | 35 | 67.7k
| 7m 33s |
| implement* | MiniMax-M3 | 1 | 3.7M | 9.0k | 0 | 0 | 79 | 0 | 7m 44s |
| audit_impl* | opus[1m] | 1 | 1.2k | 11.3k | 257.3k | 48.2k | 18 |
39.9k | 4m 33s |
| prepare_pr* | MiniMax-M3 | 1 | 284.5k | 2.9k | 0 | 0 | 12 | 0 | 1m 7s
|
| compose_pr* | MiniMax-M3 | 1 | 351.0k | 1.3k | 0 | 0 | 12 | 0 | 51s |
| review_pr* | opus[1m] | 1 | 43 | 26.5k | 980.9k | 89.0k | 36 | 68.0k |
6m 17s |
| **Total** | | | 4.4M | 89.8k | 3.6M | 97.0k | | 294.0k | 54m 42s |
\* *Step used a non-Anthropic provider; caching behavior may differ.*
## Token Efficiency
| Step | LoC Changed | cache_read/LoC | cache_write/LoC | output/LoC |
|------|-------------|----------------|-----------------|------------|
| rectify | 0 | — | — | — |
| review_approach | 0 | — | — | — |
| dry_walkthrough | 0 | — | — | — |
| implement | 243 | 0.0 | 0.0 | 37.0 |
| audit_impl | 0 | — | — | — |
| prepare_pr | 0 | — | — | — |
| compose_pr | 0 | — | — | — |
| review_pr | 0 | — | — | — |
| **Total** | **243** | 14903.1 | 1210.0 | 369.7 |
## Model Usage Breakdown
| Model | steps | uncached | output | cache_read | cache_write | time |
|-------|-------|----------|--------|------------|-------------|------|
| opus[1m] | 4 | 10.7k | 67.0k | 2.8M | 226.3k | 37m 26s |
| opus | 1 | 41 | 9.6k | 867.8k | 67.7k | 7m 33s |
| MiniMax-M3 | 3 | 4.4M | 13.2k | 0 | 0 | 9m 42s |
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>1 parent 8c2ced0 commit 61802dc
30 files changed
Lines changed: 254 additions & 50 deletions
File tree
- src/autoskillit
- fleet
- hooks/formatters
- recipes
- recipe
- server/tools
- tests
- arch
- infra
- recipe
- server
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
372 | 372 | | |
373 | 373 | | |
374 | 374 | | |
| 375 | + | |
375 | 376 | | |
376 | 377 | | |
377 | 378 | | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
378 | 382 | | |
379 | 383 | | |
380 | 384 | | |
381 | | - | |
382 | | - | |
| 385 | + | |
383 | 386 | | |
384 | 387 | | |
385 | 388 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
33 | 33 | | |
34 | 34 | | |
35 | 35 | | |
| 36 | + | |
36 | 37 | | |
37 | 38 | | |
38 | 39 | | |
| |||
170 | 171 | | |
171 | 172 | | |
172 | 173 | | |
| 174 | + | |
173 | 175 | | |
174 | 176 | | |
175 | 177 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
312 | 312 | | |
313 | 313 | | |
314 | 314 | | |
| 315 | + | |
315 | 316 | | |
316 | 317 | | |
317 | 318 | | |
| |||
517 | 518 | | |
518 | 519 | | |
519 | 520 | | |
| 521 | + | |
520 | 522 | | |
521 | 523 | | |
522 | 524 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
111 | 111 | | |
112 | 112 | | |
113 | 113 | | |
| 114 | + | |
114 | 115 | | |
115 | 116 | | |
116 | 117 | | |
| |||
133 | 134 | | |
134 | 135 | | |
135 | 136 | | |
136 | | - | |
| 137 | + | |
137 | 138 | | |
| 139 | + | |
138 | 140 | | |
139 | 141 | | |
140 | 142 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1065 | 1065 | | |
1066 | 1066 | | |
1067 | 1067 | | |
1068 | | - | |
1069 | 1068 | | |
1070 | 1069 | | |
1071 | 1070 | | |
| |||
1110 | 1109 | | |
1111 | 1110 | | |
1112 | 1111 | | |
| 1112 | + | |
| 1113 | + | |
| 1114 | + | |
1113 | 1115 | | |
1114 | 1116 | | |
1115 | 1117 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
945 | 945 | | |
946 | 946 | | |
947 | 947 | | |
948 | | - | |
949 | | - | |
| 948 | + | |
950 | 949 | | |
951 | 950 | | |
952 | 951 | | |
| |||
986 | 985 | | |
987 | 986 | | |
988 | 987 | | |
| 988 | + | |
989 | 989 | | |
990 | 990 | | |
991 | 991 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1147 | 1147 | | |
1148 | 1148 | | |
1149 | 1149 | | |
1150 | | - | |
1151 | 1150 | | |
1152 | 1151 | | |
1153 | 1152 | | |
| |||
1209 | 1208 | | |
1210 | 1209 | | |
1211 | 1210 | | |
| 1211 | + | |
| 1212 | + | |
| 1213 | + | |
1212 | 1214 | | |
1213 | 1215 | | |
1214 | 1216 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1035 | 1035 | | |
1036 | 1036 | | |
1037 | 1037 | | |
1038 | | - | |
1039 | | - | |
| 1038 | + | |
1040 | 1039 | | |
1041 | 1040 | | |
1042 | 1041 | | |
| |||
1095 | 1094 | | |
1096 | 1095 | | |
1097 | 1096 | | |
| 1097 | + | |
1098 | 1098 | | |
1099 | 1099 | | |
1100 | 1100 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1196 | 1196 | | |
1197 | 1197 | | |
1198 | 1198 | | |
| 1199 | + | |
| 1200 | + | |
| 1201 | + | |
1199 | 1202 | | |
1200 | 1203 | | |
1201 | 1204 | | |
| |||
1559 | 1562 | | |
1560 | 1563 | | |
1561 | 1564 | | |
| 1565 | + | |
| 1566 | + | |
| 1567 | + | |
1562 | 1568 | | |
1563 | 1569 | | |
1564 | 1570 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1162 | 1162 | | |
1163 | 1163 | | |
1164 | 1164 | | |
| 1165 | + | |
1165 | 1166 | | |
1166 | 1167 | | |
1167 | 1168 | | |
| |||
1523 | 1524 | | |
1524 | 1525 | | |
1525 | 1526 | | |
| 1527 | + | |
1526 | 1528 | | |
1527 | 1529 | | |
1528 | 1530 | | |
| |||
0 commit comments