Skip to content

feat(autoland): add an x step that runs a command and stops the land if it fails - #75

Merged
micahjsmith merged 1 commit into
micahjsmith/stack/8from
micahjsmith/stack/9
Oct 12, 2026
Merged

micahjsmith merged 1 commit into
micahjsmith/stack/8from
micahjsmith/stack/9

Conversation

@micahjsmith

@micahjsmith micahjsmith commented Oct 12, 2026 •

Copy link
Copy Markdown
Owner

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 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 LandSteps.

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

  • 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.
  • 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.
  • 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.
  • 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).
  • 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

Co-Authored-By: Claude Opus 5.5 (1M context) noreply@anthropic.com

…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
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.

1 participant