From 4befd6469d44c54930057960da9d6553b63f2244 Mon Sep 17 00:00:00 2001 From: Micah Smith Date: Mon, 12 Oct 2026 18:49:03 -0400 Subject: [PATCH] feat(autoland): add an x step that runs a command and stops the land if it fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Phase 7 of the autoland step-abstraction stack, and its final feature: a new plan step, `x `, 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 ` 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 `. 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