Repository navigation
feat(autoland): add an x step that runs a command and stops the land if it fails - #75
Merged
Merged
Conversation
…if it fails
## Summary
Phase 7 of the autoland step-abstraction stack, and its final feature: a new plan step, `x <command>`, which runs a command once the PRs before it have landed and stops the land unless the command exits 0. The motivation is to replace manual provisioning steps, which today are `c` steps ("provision the new tables, then confirm"), with an automated process that the autoland engine drives.
```
l 101 # Add the widgets table
x ./scripts/provision.sh --env staging
l 102 # Start writing widgets
x bash -c 'curl -fsS "$HEALTH_URL" | grep -q ok'
l 103
```
**Syntax.** `x <command>` takes a required argument. A bare `x` is rejected as `unrecognized step`, the same as a bare `w` (`bare_keyword_ok = False`). The command is kept as written, so it round-trips through `-i`. It is split with `shlex.split` and run without a shell, the same way `$EDITOR` is handled: no pipes, globs, or `$VAR` expansion. Users who need those write `x bash -c '...'`. Malformed quoting fails to parse with the line number (`Line 4: malformed command "echo 'oops": No closing quotation`). A ` #` trailing comment works as it does for other steps. One limitation, documented: `parse_plan` cuts the line at ` #` before the step sees it, so a command can't contain ` #`, even inside quotes. `x` lines never set the comment column (`aligns_comments = False`, like `c`), because a command line routinely runs long.
**Execution environment.** The step runs `argv` through a new `Commands` service (`Services.commands`; production `SubprocessCommands`, which wraps `run_shell_command`/`subprocess.run`). Output goes straight to the terminal: stdout and stderr are inherited, and autoland's own streams are flushed first. The command runs from `Services.repo.root()`, a new `Repo` method: `git rev-parse --show-toplevel` of the checkout autoland runs in, which is the temporary worktree with `--branch`. Its environment is `os.environ` plus:
- `AUTOLAND_LANDED_SHA`: `sc.landed_sha()`. That is the merge commit of the last PR this run landed, or else the head of the target branch (recorded as the run's SHA), or "" if neither can be found. This is the same commit a `w` step at that position would wait for.
- `AUTOLAND_LANDED_PRS`: the PRs the plan lands before this step, space-separated in plan order, including the already-landed prefix (from `AutolandStep.landed_pr`).
- `AUTOLAND_BRANCH` (the branch whose stack is landing: the state file's branch, now passed in as `StepContext.branch`), `AUTOLAND_REMOTE`, and `AUTOLAND_TARGET`.
`Commands` is a service of its own, not a `Repo` method, because what it runs is the user's program, not an operation on the clone. Keeping it separate also lets a step be tested against commands that just exit with a given status. There is no timeout.
**Failure and resume.** The step marks itself `running` and checkpoints before the command starts, so `--status` shows it as running. A nonzero exit, a command killed by a signal, or a command that can't be started (`OSError`: no such program, not executable) aborts the run with a clear reason, not a traceback. Examples: ``Command `./smoke-test.sh` exited with status 3``, `killed by signal 9`, `could not start: No such file or directory`. The step's state is `failed`, and its error shows as the step's detail. Ctrl+C or a takeover raises `KeyboardInterrupt` at the run boundary. `subprocess.run` kills the child on the way out, as the phase 5 contract requires. `--resume` skips a command that succeeded and runs a failed or interrupted one again, so commands should be idempotent (the README says so). Inside the already-landed prefix an `x` step is assumed completed, like `w` and `c`. `--replan` carries a finished one over when the new plan has the same command text after the same set of landed PRs. The native-stack policy already treated `x` as a run boundary, since it only groups `LandStep`s.
**State and display.** `ExecStep(command, state="pending"|"running"|"succeeded"|"failed"|"skipped", error_message)` is persisted through the registry as kind `"exec"`. The format for existing step types is unchanged, and `STATE_VERSION` stays 1. Its `StepView` uses the command as its key, its error as its detail, and `command` in the `--status -o json` data. A new `StepTone.RUNNING` (drawn blue by `display.py`) shows a running command.
**Editor support.** The VS Code extension (0.3.0) highlights the `x` keyword and the command (`string.unquoted.command`) and marks a bare `x` invalid. Its formatter collapses the gap after `x` and, as for `c`, never lets an `x` step set the comment column. The shipped example and the generated-plan fixture include an `x` step. The `-i` plan header gains two lines for `x <command>`. That changes every golden `plan.txt`, which differ only by those two lines.
**What a new step type took.** The step itself is one new module, `steps/exec.py` (registered with `@register_step`), plus two exports. Everything else in the core is a capability the step needed: the `Commands` service and `Repo.root()`, `StepContext.branch`, one tone, and the help text. The engine, the optimizer, persistence, `--status`, replanning, and the display needed no changes for the new type. The rest of the diff is docs, the editor extension, and tests.
## Test plan
- [x] New step-execution tests against fakes: success with the command split shell-style but unexpanded, run from the repo root with the `AUTOLAND_*` env; the `running` state checkpointed before the command; aborts on a nonzero exit, a signal, a missing program, and a permission error; an already-succeeded or assumed-completed step doesn't run; a failed or interrupted step runs again.
- [x] New real-subprocess test: `x <sys.executable> <script> <status> 'a b'` runs a tiny Python script with no shell, checking its args, cwd, inherited environment, and `AUTOLAND_*` variables, for both exit 0 and a failing exit. Plus a missing program reported as an abort.
- [x] New step tests: the command kept as written through a plan round trip; parse errors (bare `x`, `x` followed only by a comment, unclosed quote, ` #` inside quotes); assumed completed inside the landed prefix; replan credit only for a finished command with the same text after the same PRs; the view for each state. `ExecStep` added to the registry coverage samples.
- [x] New golden scenario `exec_steps`: one `x` succeeds between two lands and a second fails with status 3, which stops the run before the last PR (output, checkpoint, `--status` text and JSON, plan).
- [x] VS Code grammar assertions and formatter tests for `x` steps.
- [ ] Manual: run an `x` step in a real autoland. Include Ctrl+C during a long-running command (check that the command is stopped and the checkpoint shows it running), then `--resume` (check that it runs again).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
stack-info: PR: #75, branch: micahjsmith/stack/9
micahjsmith
force-pushed
the
micahjsmith/stack/9
branch
from
October 12, 2026 23:05
a9b630f to
4befd64
Compare
This was referenced Oct 12, 2026
micahjsmith
added this pull request to stack #77
October 12, 2026 23:22
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.
Stacked PRs:
Summary
Phase 7 of the autoland step-abstraction stack, and its final feature: a new plan step,
x <command>, which runs a command once the PRs before it have landed and stops the land unless the command exits 0. The motivation is to replace manual provisioning steps, which today arecsteps ("provision the new tables, then confirm"), with an automated process that the autoland engine drives.Syntax.
x <command>takes a required argument. A barexis rejected asunrecognized step, the same as a barew(bare_keyword_ok = False). The command is kept as written, so it round-trips through-i. It is split withshlex.splitand run without a shell, the same way$EDITORis handled: no pipes, globs, or$VARexpansion. Users who need those writex bash -c '...'. Malformed quoting fails to parse with the line number (Line 4: malformed command "echo 'oops": No closing quotation). A#trailing comment works as it does for other steps. One limitation, documented:parse_plancuts the line at#before the step sees it, so a command can't contain#, even inside quotes.xlines never set the comment column (aligns_comments = False, likec), because a command line routinely runs long.Execution environment. The step runs
argvthrough a newCommandsservice (Services.commands; productionSubprocessCommands, which wrapsrun_shell_command/subprocess.run). Output goes straight to the terminal: stdout and stderr are inherited, and autoland's own streams are flushed first. The command runs fromServices.repo.root(), a newRepomethod:git rev-parse --show-toplevelof the checkout autoland runs in, which is the temporary worktree with--branch. Its environment isos.environplus:AUTOLAND_LANDED_SHA:sc.landed_sha(). That is the merge commit of the last PR this run landed, or else the head of the target branch (recorded as the run's SHA), or "" if neither can be found. This is the same commit awstep at that position would wait for.AUTOLAND_LANDED_PRS: the PRs the plan lands before this step, space-separated in plan order, including the already-landed prefix (fromAutolandStep.landed_pr).AUTOLAND_BRANCH(the branch whose stack is landing: the state file's branch, now passed in asStepContext.branch),AUTOLAND_REMOTE, andAUTOLAND_TARGET.Commandsis a service of its own, not aRepomethod, because what it runs is the user's program, not an operation on the clone. Keeping it separate also lets a step be tested against commands that just exit with a given status. There is no timeout.Failure and resume. The step marks itself
runningand checkpoints before the command starts, so--statusshows it as running. A nonzero exit, a command killed by a signal, or a command that can't be started (OSError: no such program, not executable) aborts the run with a clear reason, not a traceback. Examples:Command `./smoke-test.sh` exited with status 3,killed by signal 9,could not start: No such file or directory. The step's state isfailed, and its error shows as the step's detail. Ctrl+C or a takeover raisesKeyboardInterruptat the run boundary.subprocess.runkills the child on the way out, as the phase 5 contract requires.--resumeskips a command that succeeded and runs a failed or interrupted one again, so commands should be idempotent (the README says so). Inside the already-landed prefix anxstep is assumed completed, likewandc.--replancarries a finished one over when the new plan has the same command text after the same set of landed PRs. The native-stack policy already treatedxas a run boundary, since it only groupsLandSteps.State and display.
ExecStep(command, state="pending"|"running"|"succeeded"|"failed"|"skipped", error_message)is persisted through the registry as kind"exec". The format for existing step types is unchanged, andSTATE_VERSIONstays 1. ItsStepViewuses the command as its key, its error as its detail, andcommandin the--status -o jsondata. A newStepTone.RUNNING(drawn blue bydisplay.py) shows a running command.Editor support. The VS Code extension (0.3.0) highlights the
xkeyword and the command (string.unquoted.command) and marks a barexinvalid. Its formatter collapses the gap afterxand, as forc, never lets anxstep set the comment column. The shipped example and the generated-plan fixture include anxstep. The-iplan header gains two lines forx <command>. That changes every goldenplan.txt, which differ only by those two lines.What a new step type took. The step itself is one new module,
steps/exec.py(registered with@register_step), plus two exports. Everything else in the core is a capability the step needed: theCommandsservice andRepo.root(),StepContext.branch, one tone, and the help text. The engine, the optimizer, persistence,--status, replanning, and the display needed no changes for the new type. The rest of the diff is docs, the editor extension, and tests.Test plan
AUTOLAND_*env; therunningstate checkpointed before the command; aborts on a nonzero exit, a signal, a missing program, and a permission error; an already-succeeded or assumed-completed step doesn't run; a failed or interrupted step runs again.x <sys.executable> <script> <status> 'a b'runs a tiny Python script with no shell, checking its args, cwd, inherited environment, andAUTOLAND_*variables, for both exit 0 and a failing exit. Plus a missing program reported as an abort.x,xfollowed only by a comment, unclosed quote,#inside quotes); assumed completed inside the landed prefix; replan credit only for a finished command with the same text after the same PRs; the view for each state.ExecStepadded to the registry coverage samples.exec_steps: onexsucceeds between two lands and a second fails with status 3, which stops the run before the last PR (output, checkpoint,--statustext and JSON, plan).xsteps.xstep in a real autoland. Include Ctrl+C during a long-running command (check that the command is stopped and the checkpoint shows it running), then--resume(check that it runs again).🤖 Generated with Claude Code
Co-Authored-By: Claude Opus 5.5 (1M context) noreply@anthropic.com