Skip to content

Bundled implementation recipe rejects make-plan dispatch with output_dir='.' #5256

Closed

Description

@Trecek

Problem

The bundled implementation recipe cannot reach its planning worker: the plan
step passes output_dir: . to /autoskillit:make-plan, whose declared write scope
is {{AUTOSKILLIT_TEMP}}/make-plan/. The shared run_skill scope check correctly
rejects the repository root before the planner session can start.

Live evidence

Source evidence

The recipe, make-plan declaration, and shared containment helper are unchanged
between PR #5255's base df70657d0e18f7c1bd590666b49cc1c9d31ad626 and its tested
head. This is a pre-existing production dispatch mismatch surfaced by the new
E2E harness, not a recipe regression introduced by the harness PR.

Deterministic Docker reproduction

An archive of the exact tested source was streamed into an existing local image.
The disposable container had a read-only root filesystem, no network or host
mounts, one CPU, a 1 GB memory/swap limit, and bounded temporary filesystems.
It parsed the actual recipe and skill declaration and called the actual shared
containment checker. No model session, test suite, host installation, or host
settings were involved.

{
  "configured_output_dir": ".",
  "configured_output_dir_allowed": false,
  "candidate_output_dir": ".autoskillit/temp/make-plan/",
  "candidate_output_dir_allowed": true,
  "peak_python_rss_kb": 56272
}

Artifacts: /home/talon/projects/generic_automation_mcp/.autoskillit/temp/prepare-issue/docker-scope-repro/.

Self-contained reproduction for another investigator

Prerequisites: Docker, Git, and a checkout containing commit
017b3f182e9ca81d8cecfc6539d01721dd8632d3. No MiniMax key, sandbox token, running
MCP server, or host Python environment is needed for this deterministic probe.

The original existing image was autoskillit-test:345e37f8007b68913b51cdf72132e327183dc9ed
(image ID 3ba87731aa0a). It supplied Python and dependencies; the code under test
was the streamed archive of 017b3f182, not the older package inside the image
.
That image is local, so the following builds the repository's user image if an
investigator does not already have a suitable AutoSkillit image. Building requires
network access; the subsequent probe does not.

From the repository checkout:

REPRO_DIR=.autoskillit/temp/planning-scope-37854525992
REPRO_SHA=017b3f182e9ca81d8cecfc6539d01721dd8632d3
mkdir -p "$REPRO_DIR/build-context"
git archive "$REPRO_SHA" | tar -xf - -C "$REPRO_DIR/build-context"
docker build --target user --tag autoskillit-scope-repro:017b3f182 \
  --file "$REPRO_DIR/build-context/scripts/docker/Dockerfile" \
  "$REPRO_DIR/build-context"

Save this complete script as $REPRO_DIR/probe.py:

import json
import os
import resource
from pathlib import Path

import yaml

from autoskillit.hooks._write_scope import bounded_scope_contains, decode_write_scope

source = Path('/work/src/autoskillit')
recipe = yaml.safe_load((source / 'recipes/implementation.yaml').read_text())
frontmatter = yaml.safe_load(
    (source / 'skills_extended/make-plan/SKILL.md').read_text().split('---', 2)[1]
)
scope = decode_write_scope(frontmatter['write_paths'])
output_dir = recipe['steps']['plan']['with']['output_dir']
project_dir = '/work/sandbox'
Path(project_dir).mkdir()
os.chdir(project_dir)
configured_allowed = bounded_scope_contains(scope, output_dir, project_dir)
candidate_allowed = bounded_scope_contains(
    scope, '.autoskillit/temp/make-plan/', project_dir
)
assert output_dir == '.'
assert configured_allowed is False
assert candidate_allowed is True
print(json.dumps({
    'source_commit': '017b3f182e9ca81d8cecfc6539d01721dd8632d3',
    'declared_write_paths': frontmatter['write_paths'],
    'configured_output_dir': output_dir,
    'configured_output_dir_allowed': configured_allowed,
    'candidate_output_dir_allowed': candidate_allowed,
    'peak_python_rss_kb': resource.getrusage(resource.RUSAGE_SELF).ru_maxrss,
}, indent=2))

Then archive the exact source and stream it into a disposable container:

git archive "$REPRO_SHA" src/autoskillit --output="$REPRO_DIR/source.tar"
tar -rf "$REPRO_DIR/source.tar" -C "$REPRO_DIR" probe.py
docker run --rm -i --network=none --memory=1g --memory-swap=1g \
  --cpus=1 --pids-limit=128 --read-only --cap-drop=ALL \
  --security-opt=no-new-privileges \
  --tmpfs /work:rw,size=128m,mode=1777 \
  --tmpfs /tmp:rw,size=32m,mode=1777 \
  --workdir /work --entrypoint /bin/sh autoskillit-scope-repro:017b3f182 \
  -c 'set -eu
      tar -xf - -C /work
      if [ -x /opt/uv-tools/autoskillit/bin/python ]; then
        probe_python=/opt/uv-tools/autoskillit/bin/python
      else
        probe_python=/home/autoskillit/.local/share/uv/tools/autoskillit/bin/python
      fi
      PYTHONPATH=/work/src "$probe_python" -B /work/probe.py' \
  < "$REPRO_DIR/source.tar"

Expected: exit 0, configured_output_dir_allowed=false, and
candidate_output_dir_allowed=true. Peak memory can vary. This demonstrates the
dispatch contract mismatch; it does not execute a planner session or establish
that the entire implementation recipe succeeds after changing the argument.

The portable interpreter-selection form above was also run against the existing
autoskillit-user:local image (ID 3438dcd579eb) with the same exact source archive,
resource limits, and container project cwd. It exited 0 with the same rejection
and admission results; recorded peak Python RSS was 56552 KiB. This validates the
user-image interpreter path and the streamed-source reproduction. The optional
fresh image build command was not rerun locally; it mirrors the user-target build
from the successful setup phase of the linked CI run.

Retrieve the live failure artifacts

With GitHub access to the public run:

gh run view 37854525992 --repo TalonT-Org/AutoSkillit --log-failed
gh run download 37854525992 --repo TalonT-Org/AutoSkillit \
  --name e2e-implementation-37854525992-1 \
  --dir "$REPRO_DIR/live-artifacts"

Inspect out/envelope.json, out/result.json,
out/baseline-install.command.json, and out/baseline-test.command.json.
Artifacts may expire according to Actions retention; the quoted terminal error,
source revision, and self-contained probe remain usable after expiration.

Repair direction

Correct the recipe's planner output-directory selection while preserving plan
captures and write-scope enforcement. Do not widen the planner's write scope to
the repository root. Check other actual planner dispatches for the same mismatch
and add a focused recipe-to-skill boundary regression test.

Verify planning admission in isolated Docker, then rerun the full implementation
scenario on MiniMax twice against the same sandbox seed before claiming successful
commissioning or a measured full-pipeline concurrency peak.

Related issues and deduplication

Distinct from #4376, which concerns an implementation worker's clone cwd and a
misleading output-dir diagnostic. Here the explicit recipe output directory itself
is outside the planner's declared boundary. The reporter crash is already tracked
in #5249 and is not part of this issue.

Investigation findings (2026-10-09)

How this was checked:

  • Investigated on develop at df70657d0. Paths and line numbers refer to that commit.
  • Three adversarial validators reviewed the draft. Every correction was re-checked against source before it was applied.
  • Full report (local): .autoskillit/temp/investigate/investigation_make_plan_output_dir_write_scope_2026-10-09_091407.md.

Root cause

The checks on run_skill output_dir disagree about BOUNDED skills, meaning skills whose write_paths is a list of temp directories.

1. The runtime check is correct.

  • _extend_closure_write_scope (src/autoskillit/server/tools/tools_execution/_run_skill_session.py:598-630) rejects an explicit output_dir outside a BOUNDED skill's declared scope.
  • . resolves to the worktree root (_resolve_dispatch_paths, :147-182), and that root would become the child session's write-guard prefix. That is a widening, which ADR 0015 (docs/decisions/0015-write-containment.md:9,26-27) rules out.
  • Every backend and every resume reaches this check through _prepare_owned_dispatch_session (:364). Nothing normalizes . first.

2. The static rule skill-write-path-recipe-alignment has drifted.

3. The rule write-skill-requires-source-output-dir and its twin test have a stale premise.

  • The rule is at src/autoskillit/recipe/rules/rules_contracts.py:712-761 (ERROR); the test is tests/recipe/test_planner_contracts.py:43.
  • Both require output_dir on every skill with write_behavior always or conditional. The rule's rationale is "the write guard defaults to a temp-dir prefix that blocks source-file edits".
  • The rule suggests ${{ context.work_dir }}, which the runtime rejects for BOUNDED skills.
  • 0dddf08b4 ([FIX] Rectify: Write Guard Source-Edit Blocks & Push-Without-Rebase Immunity #3167) created this rule. In the same commit it added output_dir: '.' to the write-behavior steps, make-plan's plan step included, to satisfy it.

How it went live:

Scope: 17 tracked dispatch sites, not one

All of these pass output_dir: . to a BOUNDED skill. All are under src/autoskillit/recipes/. The inventory was recomputed independently three times, and removing the static pre-filter in memory produces exactly these findings.

Skill Sites
make-plan (10) implementation.yaml:437 (plan); implementation-groups.yaml:241 (plan), :397 (gap_replan); remediation.yaml:1478 (make_plan); merge-prs.yaml:451, :738, :929, :1557; research.yaml:532 (plan_phase); research-implement.yaml:190 (plan_phase)
prepare-pr (3) implementation.yaml:1334; implementation-groups.yaml:1166; remediation.yaml:1660
prepare-research-pr (2) research.yaml:1139; research-review.yaml:104
compose-research-pr (2) research.yaml:1214; research-review.yaml:169
  • Fixing only the plan step is not enough. Fixing only implementation.yaml:437 moves the implementation pipeline's failure to prepare_pr (:1334).
  • A local recipe has 3 more sites. The git-excluded .autoskillit/recipes/remediation-fable.yaml has them at :935 (make-plan), :1110 (prepare-pr) and :1163 (compose-pr).
    • A PR cannot change that file.
    • Migration notes skip recipes without autoskillit_version (src/autoskillit/migration/loader.py:70-77).
  • Other . targets are fine. Every other step passing . targets an UNRESTRICTED skill (implement-worktree-no-merge, resolve-failures and the like), which is correct.

Why tests missed it

  • The static rule's test cannot fail on this input. test_rule_silent_on_fixed_bundled_recipes (tests/recipe/test_rules_skill_write_path_alignment.py:286-302) asserts zero findings, and the pre-filter guarantees zero findings for exactly the failing input.
  • Runtime unit tests never pair . with a BOUNDED skill.
    • tests/server/test_tools_execution_write_prefix.py:367-404 covers outside, inside, absent and unbounded cases.
    • Generic fixtures declare write_paths: inherit (tests/conftest.py:821-840).
    • The real make-plan dispatch test passes no output_dir (tests/server/test_tools_execution_routing.py:270-289).
  • Recipe-driven admission tests use only in-scope values. They exist (tool_ctx_ready_recipe, default research/scope), but none drives an affected step.
  • Two checks require the rejected value. The write-skill rule and test_write_skill_steps_have_output_dir both require an output_dir that the runtime rejects.
  • No end-to-end coverage. develop had no end-to-end implementation scenario before PR Add seeded sandbox implementation E2E validation #5255.

Recommended repair

1. Make all the checks agree. Land this in the same part as item 2: either half alone fails task test-local-gate.

  • Runtime:
    • Keep the current semantics. Do not special-case . and do not widen any scope.
    • Make the error name the skill, the requested output_dir and the declared directories (_run_skill_session.py:622-624).
  • Alignment rule:
    • Remove the pre-filter in front of the boundary check.
    • Explicitly treat ${{ context.work_dir }} and slash-only values as the worktree root. Removing the pre-filter alone does not catch them: _static_base_prefix reduces them to "", and _declared_boundary_error:82-83 then returns early.
    • Keep the iteration-scoping check's own treatment of ..
  • write-skill-requires-source-output-dir and test_write_skill_steps_have_output_dir:
    • Make both aware of the write-scope kind, and require output_dir only for UNRESTRICTED write-behavior skills.
    • Drop the suggestion that the runtime rejects.
    • If rules_contracts.py imports autoskillit.hooks._write_scope, add exemptions in tests/arch/test_import_paths.py:131-136 and tests/arch/test_layer_enforcement.py:1173-1197.

2. Drop output_dir: . from the 17 sites.

  • run_skill's default directory (resolve_skill_temp_dir, src/autoskillit/core/io/io.py:137-147) is <cwd>/.autoskillit/temp/<skill>, exactly each skill's declared scope.
  • What I verified stays the same:
    • Plan captures come from output tokens, not output_dir.
    • Plans still land in make-plan/, so the implement gate's allowed_plan_dirs check passes.
    • Attestation admits an omitted value (src/autoskillit/recipe/_binding.py:632-636).
    • Clone-guard policy and write evidence are unchanged.
  • What changes: the write-watch snapshot shrinks from the whole worktree to the skill's directory, which is relevant to Broad run_skill output watches let unrelated temp mutations satisfy report-skill progress #4258.
  • Precedent: the arch-lens and analyze-pipeline-health steps already dispatch BOUNDED skills without output_dir.
  • Alternative not chosen: an explicit '{{AUTOSKILLIT_TEMP}}/<skill>', following the fix: contain interactive writes and verify installations #5114 compose-pr precedent.
    • The runtime admits it.
    • But loop-iterated-step-requires-iteration-scoped-output (ERROR) fires on the 9 tracked steps that sit in routing cycles: 8 make-plan and 1 prepare_pr.
    • Iteration-scoped subdirectories would be cosmetic. These skills ignore output_dir, and make-plan must write directly into make-plan/ for the implement gate.
  • Regenerate the compiled recipe JSON (task compile-recipes), the contract cards and the 5 diagram hash lines.

3. Regression tests:

  • Runtime: . with a BOUNDED skill is rejected.
  • Alignment rule: . and ${{ context.work_dir }} produce a finding with BOUNDED skills and none with UNRESTRICTED skills.
  • The write-skill rule and its test exempt BOUNDED skills.
  • Parametrize tool_ctx_ready_recipe indirectly with an affected step, for example ("implementation", "plan"), so the real run_skill admission path is exercised.
  • Add a contract test over the tracked bundled recipes, with a positive control.

4. Then run the commissioning plan above: an isolated Docker admission check, then two MiniMax runs of the PR #5255 scenario.

Related, out of scope here

Scope update (2026-10-09): vis_apply is fixed under this issue

The investigation findings above listed vis_apply as "Related, out of scope here" and proposed a separate issue. It is now in scope: this issue tracks both defects, and they ship in one PR.

Why it belongs here:

  • Same defect class. A recipe step passes an output_dir outside a BOUNDED skill's declared write_paths.
  • Confirmed at runtime.
    • The real _resolve_dispatch_paths + _extend_closure_write_scope reject vis-lens-always-on, vis-lens-methodology-norms and vis-lens-chart-select given output_dir='.autoskillit/temp/run-vis-lenses'. The error is run_skill output_dir is outside the skill's declared write scope.
    • All three are admitted when output_dir is omitted.
    • _apply_step_tuning_fallback re-injects the recipe value, so an orchestrator cannot avoid the rejection.
  • Synthesis reads the wrong place. vis_synthesize reads {{AUTOSKILLIT_TEMP}}/run-vis-lenses, which no skill writes. Each lens writes {{AUTOSKILLIT_TEMP}}/vis-lens-<slug>/vis_spec_*.md, and the captured vis_lens_output_paths is never consumed.
  • Small and coupled.
    • The fix is about 25–35 hand-written lines, in recipes this fix already edits and regenerates.
    • The new bundled-dispatch admission test fails on any templated-skill step that passes an output_dir. With vis_apply fixed here, that test needs no allowlist.

Sites:

  • src/autoskillit/recipes/research.yaml: vis_apply output_dir (:290) and vis_synthesize capture_dir (:313).
  • src/autoskillit/recipes/research-design.yaml: vis_apply output_dir (:228) and vis_synthesize positional capture argument (:241).

Repair:

  • Drop output_dir from both vis_apply steps. Each lens then gets its default floor, which equals its declared directory.
  • Pass the lens output paths to synthesis.
    • vis_synthesize consumes ${{ context.vis_lens_output_paths }} through a new synthesize-vis-plan input, lens_output_paths. It is a comma-separated string and replaces capture_dir, following the existing compose-research-pr all_diagram_paths precedent.
    • research-design also declares optional_context_refs: [vis_lens_output_paths], because its vis_apply routes failures to synthesis. Synthesis already handles zero figures.
  • Update the affected docs and tests. That means synthesize-vis-plan/SKILL.md, tests/contracts/test_synthesize_vis_plan_contracts.py and the phoropter docs.
  • Leave the generic synthesizers alone. The phoropter-null-synthesis and phoropter-priority-synthesis contracts keep capture_dir. No bundled recipe dispatches them.

Regression coverage:

  • A bundled-recipe data-flow test asserts that vis_synthesize consumes vis_apply's captured paths.
  • The bundled-dispatch admission sweep fails on any templated-skill output_dir.
  • A vis-lens-always-on positive control covers the pre-fix value.

Rectify plan (local): .autoskillit/temp/rectify/rectify_bounded_output_dir_write_target_authority_2026-10-09_150011_combined.md.

Activity

  1. added
    bugExisting behavior is broken
    recipe:remediationRoute: investigate/decompose before implementation
    on Oct 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugExisting behavior is brokenrecipe:remediationRoute: investigate/decompose before implementation

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions