diff --git a/GLOSSARY.md b/GLOSSARY.md index e683eb2bb..5b2f5c141 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -116,7 +116,7 @@ One invocation of the factory with no **Issue URL**: a **Security audit** of the _Avoid_: Fencing (former name), Secure run (it reads as a run that is secure), audit run, security pass **Pass**: -An **Architect run**, a **Pickup run**, a **Security run**, or a **Custom process** started with no issue: a **Command** started with no **Issue URL**, which decides before any work whether it is skipped. A Custom process started with no issue is skipped while another Pass on the same repository is still running on the machine, its Spec run or Ticket run included, and it does not take an Architect run's Ready-issue or Architect-idea gates. A Custom process started with an issue is not a Pass. +An **Architect run**, a **Pickup run**, a **Security run**, or a **Custom process** started with no issue and no Issue-branch worktree: a **Command** started with no **Issue URL**, which decides before any work whether it is skipped. A Custom process started with no issue and no Issue-branch worktree is skipped while another Pass on the same repository is still running on the machine, its Spec run or Ticket run included, and it does not take an Architect run's Ready-issue or Architect-idea gates. A file that declares an Issue-branch worktree is refused when no issue is given, before any session, and is not a Pass. A Custom process started with an issue is not a Pass. _Avoid_: tick, cycle **Process**: @@ -124,7 +124,7 @@ A named sequence of **Steps** that a **Command** carries out. The one a person w _Avoid_: workflow, pipeline, recipe **Custom process**: -A **Process** a person defines on their own machine and starts by name. Started with no issue, it is a **Pass**. Started with an issue, it is not. +A **Process** a person defines on their own machine and starts by name. Started with no issue and no Issue-branch worktree, it is a **Pass**. A file that declares an Issue-branch worktree is refused when no issue is given. Started with an issue, it is not a Pass. _Avoid_: workflow, pipeline **Step**: diff --git a/README.md b/README.md index 6c01da4d6..9409e2254 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Use `thirdshift help` for the command reference, or read [Ticket run behavior](d | A security audit | `thirdshift secure` | A [Security run](docs/guide.md#security-runs) records findings privately; fixing a reproduced finding is optional. | | Merged factory work | `thirdshift score` | A [Scoring run](docs/guide.md#scoring-runs) samples and grades it, and proposes Bug origins; `thirdshift scores --json` lists samples, five criterion Scores, calibration and proposals by week; `thirdshift scores --mine` records yours before revealing the model's. | | One skill | `thirdshift skill [issue ]` | A [standalone skill](docs/guide.md#standalone-skills) runs headlessly and prints its final message. | -| A Custom process | `thirdshift process [issue ]` | An experimental [Custom process](docs/guide.md#custom-processes) runs skill, command or catalogue Steps from `~/.thirdshift/processes/.toml`, in named throwaway worktrees when the file declares them. The catalogue Step `run-published-issue` starts a Ticket run or a Spec run on the issue the session Step before it published. An optional Step can fail without stopping the process. `issue ` gives every session Step that issue. With no issue it is a Pass and skips while another Pass on the repository is running. Status, the Console and the Activity log list it as Process ``. A failed one is in Needs you, and it sends a Run notification under the same email rules as a Ticket run. A skipped Process sends none. | +| A Custom process | `thirdshift process [issue ]` | An experimental [Custom process](docs/guide.md#custom-processes) runs skill, command or catalogue Steps from `~/.thirdshift/processes/.toml`, in named throwaway worktrees when the file declares them. The catalogue Step `run-published-issue` starts a Ticket run or a Spec run on the issue the session Step before it published. An optional Step can fail without stopping the process. `issue ` gives every session Step that issue. One Issue-branch worktree plus a `delivery` Step publishes a ready-for-review pull request from that Issue branch and then removes the Claim. With no issue, and no Issue-branch worktree, it is a Pass and skips while another Pass on the repository is running. Status, the Console and the Activity log list it as Process ``. A failed one is in Needs you, and it sends a Run notification under the same email rules as a Ticket run. A skipped Process sends none. | Each Architect, Pickup or Security run makes one pass. Use cron or another operating system scheduler to repeat it. See [scheduling](docs/guide.md#on-a-schedule), [Architect runs every few minutes](docs/guide.md#architect-runs-every-few-minutes) and [Security runs on a schedule](docs/guide.md#security-runs-on-a-schedule-with-fixing-allowed). diff --git a/docs/code-navigation.md b/docs/code-navigation.md index 5c1fe49ad..9c8e7c2e1 100644 --- a/docs/code-navigation.md +++ b/docs/code-navigation.md @@ -70,7 +70,7 @@ Start with the issue's terms in `GLOSSARY.md`, then use this index to find their | Bug-origin proposal | `src/scoring/origins.rs` — bounded history session and proposed attribution; `src/github/scoring/origins.rs` — closed bugs and commit-to-PR associations; `src/logs/reading.rs` — retained Run lookup; `src/session/private.rs` — shared sandboxed analysis with native quota observation | `tests/bug_origins.rs` | | Bug-origin settlement, confirmed Run count | `src/scoring/origins/settlement.rs` — CLI decisions saved through retained handles before superseded Eval proposals are withdrawn; `src/scoring/store/confirmed.rs` — narrow confirmed-origin projection and normalized Bug-origin supersession over shared ordered storage, exposed by `scoring::read_confirmed_origins`; `src/logs/metrics/bug_origins.rs` — confirmed PR attribution and counts against the captured metrics Snapshot; `src/github/scoring/origins/review.rs` — historical PR head and issue evidence; `src/evals/bug_origin.rs` — private proposed Eval tasks; `src/logs/reading/selection.rs` — Run details | `src/scoring/store/confirmed/tests.rs`, `tests/metrics.rs`, `tests/bug_origins/settlement.rs`, `tests/bug_origins/regressions.py`, `tests/console/metrics.rs` | | Architecture review, Architect run | `src/architect.rs` | `tests/architect_run.rs` | -| Custom process, run-published-issue | `src/processes.rs` — `execute`, `run_published` | `tests/process.rs` | +| Custom process, Issue branch, delivery, run-published-issue | `src/processes.rs` — `execute`, `run_delivery`, `run_published` | `tests/process.rs` | | Security audit, Security review, Security run | `src/security.rs` and `src/security/`; which Ticket runs run a Security review, and what every child Ticket run is handed: `src/child_run/asks.rs` — `ChildAsks` | Inline tests; `tests/security_review.rs`, `tests/security_run.rs` | Eval task manifests, suite dependency selection and repository preparation live in diff --git a/docs/guide.md b/docs/guide.md index 2acba63e3..3e7397e2b 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -17,7 +17,7 @@ A [Ticket run](#what-a-ticket-run-does), one issue in and one pull request out, - **[Pickup run](#pickup-runs)**, `thirdshift pickup`: takes the lowest-numbered **Ready issue** by default, the highest-numbered with `pickup.order = "highest-first"` or `highest-first` on the command, and runs it. - **[Security run](#security-runs)**, `thirdshift secure`: audits the **Base branch** and records each **Security finding** privately. It is report-only by default; with fixing allowed, it fixes one finding it reproduced. -Architect, Pickup and Security runs are **Passes**, as is a Custom process started with no issue: they take no Issue URL, so your operating system's scheduler can start them. A Custom process started with an issue is not a Pass. See [A Pickup run on a schedule](#a-pickup-run-on-a-schedule), [Architect runs every few minutes](#architect-runs-every-few-minutes) for Architect runs, and [Security runs on a schedule, with fixing allowed](#security-runs-on-a-schedule-with-fixing-allowed) for Security runs. +Architect, Pickup and Security runs are **Passes**, as is a Custom process started with no issue and no Issue-branch worktree: they take no Issue URL, so your operating system's scheduler can start them. A Custom process started with an issue is not a Pass. A file that declares an Issue-branch worktree is refused when no issue is given, before any session, rather than skipped. See [A Pickup run on a schedule](#a-pickup-run-on-a-schedule), [Architect runs every few minutes](#architect-runs-every-few-minutes) for Architect runs, and [Security runs on a schedule, with fixing allowed](#security-runs-on-a-schedule-with-fixing-allowed) for Security runs. Every session runs on the **Harness** you choose: Claude Code by default, or Codex, Antigravity CLI, Grok Build, Muse Code or OpenCode. See [Harness, Model and Effort](#harness-model-and-effort). @@ -506,7 +506,7 @@ thirdshift secure base # do that with as the Base branch, thirdshift skill [issue ] # run one skill headlessly and print its final message thirdshift process # list Custom processes (experimental) thirdshift process [issue ] # run a Custom process, optionally on an issue; email flags match a Ticket run (experimental) -thirdshift process check # validate one and print its plan (experimental) +thirdshift process check [issue ] # validate one and print its plan (experimental) thirdshift score # sample merged factory pull requests (see Scoring runs) thirdshift scores [] [--json] # show Scores and calibration thirdshift scores --mine [--json] # enter your five Scores @@ -843,8 +843,9 @@ Command record, Console entry or Session log is created for a standalone skill. ### Custom processes A Custom process runs Steps in order, and can run consecutive Steps together. -A Step is a skill session, a command, or a catalogue Step. It is experimental. Write -`~/.thirdshift/processes/.toml`. The name is the file's stem. +A Step is a skill session, a command, or a catalogue Step. It is +experimental. Write `~/.thirdshift/processes/.toml`. The name is the +file's stem. ```toml version = 1 @@ -909,6 +910,63 @@ skill = "thirdshift-to-spec" worktree = "judge" ``` +One worktree may instead be an Issue branch, and one Step may publish it: + +```toml +version = 1 +description = "Ship a fix" + +[worktrees.fix] +kind = "issue-branch" + +[[steps]] +name = "implement" +skill = "thirdshift-implement" +worktree = "fix" + +[[steps]] +name = "ship" +catalogue = "delivery" +worktree = "fix" +``` + +```sh +thirdshift process check ship issue 7 +thirdshift process ship issue 7 +``` + +A declared worktree's `kind` is `throwaway` or `issue-branch`. A throwaway +worktree is detached at the Base branch. An Issue-branch worktree is the +Issue branch a Ticket run would use for the issue the command names: +`issue-`, then `issue--branch-` for k ≥ 2. A Continuation reuses +the existing branch, and an open pull request's base is the Base branch. +Session Steps in that checkout are told to build on its commits. A +Process has at most one Issue-branch worktree. The Steps that name it run in +that checkout. Any other declared worktree stays throwaway. + +`catalogue = "delivery"` publishes that Issue branch. It is Delivery with no +opening session and no Security review: push, open a draft pull request when +the branch has none, write the Built-with line, mark the pull request ready +for review, and keep it mergeable with green or absent CI through the Repair +loop. It does not Self-merge, even when the User config asks Ticket runs to. +When thirdshift opens the pull request, its title is the issue's and its body +starts `Closes #`. Repairs run on the Command's Harness, Model and Effort. +A Process has at most one delivery Step. It names the Issue-branch worktree, +it is not optional, and it is not in a parallel group. It comes after the +last Step that uses that worktree. + +An Issue-branch worktree needs an issue. `thirdshift process ` and +`thirdshift process check ` refuse the file before any session, any +Claim and any branch, naming the `issue` rule and the file line. With an +issue, the run makes the Claim when it starts, labelling the issue +`in-progress` in place of `ready-for-agent`, and holds it until the end. A +pull request left ready for review removes the Claim and does not put +`ready-for-agent` back, so Pickup does not take an issue that already has an +open pull request. A Step that fails before Delivery keeps the Issue-branch +work as a Failed ticket run does, and the failure says where it stopped, what +was preserved and the command `thirdshift process issue `. The +Claim stays when that work reached origin, and is released when it did not. + `version` must be `1`. Each Step has a `name` and exactly one kind. `skill` is looked up the same way as `thirdshift skill`. First a Factory skill whose directory is exactly that name. Then the repository's committed @@ -923,22 +981,21 @@ skills are under its own name, as a copy written out beside those skills, and kept out of git. A repository skill of the same name is not replaced. `command` is a command or script, run with `sh -c` in the Step's worktree. It starts no session. `catalogue` names a -built-in Step. The only one is `run-published-issue`. It starts a Ticket run, or a -Spec run when the issue has Tickets, on the issue the session Step before it -published. That session's final message ends with the issue's URL on its last -non-blank line. Before it starts a run, thirdshift checks that the issue is in -this repository, which it checks before viewing the issue, that the issue is -open, that a Trusted account published it, and that it was created since that -session Step began. A final message without that URL, or an issue that fails a -check, fails the Step, names the reason, and starts no run. The child run is -part of this Command. Its Tickets, when the issue is a Spec, run at once up to -`spec.parallel` in the User config. The child run's outcome is the Step's -outcome, and the Step's result is the pull request's URL. The run uses the -Process's Base branch and the Command's Harness. `run-published-issue` is the -Step immediately after a session Step, and it does not share that Step's -`parallel` group. A declared -worktree's `kind` must be `throwaway`: it is detached at the Base branch. When -the Process declares worktrees, every Step names one with `worktree`. +built-in Step: `delivery`, described above, or `run-published-issue`. +`run-published-issue` starts a Ticket run, or a Spec run when the issue has +Tickets, on the issue the session Step before it published. That session's final +message ends with the issue's URL on its last non-blank line. Before it starts a +run, thirdshift checks that the issue is in this repository, which it checks +before viewing the issue, that the issue is open, that a Trusted account +published it, and that it was created since that session Step began. A final +message without that URL, or an issue that fails a check, fails the Step, names +the reason, and starts no run. The child run is part of this Command. Its +Tickets, when the issue is a Spec, run at once up to `spec.parallel` in the User +config. The child run's outcome is the Step's outcome, and the Step's result is +the pull request's URL. The run uses the Process's Base branch and the +Command's Harness. `run-published-issue` is the Step immediately after a session +Step, and it does not share that Step's `parallel` group. When the Process +declares worktrees, every Step names one with `worktree`. Consecutive Steps with the same `parallel` name run at the same time. Every session or command Step edits the worktree it runs in, so Steps in one group cannot share a worktree. The same group name after a Step that is not in the @@ -949,7 +1006,9 @@ failure stops the Process once the other Steps in its group have finished. ```sh thirdshift process thirdshift process check gather-review +thirdshift process check ship issue 7 thirdshift process gather-review +thirdshift process ship issue 7 ``` `thirdshift process` lists each Custom process and its description. `check` @@ -957,17 +1016,26 @@ validates the file and prints the plan: the description, the Command's Harness, Model and Effort, and each Step. A Process with no named worktrees prints one throwaway worktree at the Base branch. A Process that declares worktrees prints each one, the worktree each Step uses, and a `together:` line for each group -of Steps that run at the same time. A skill Step shows the skill and where it -was found. A command Step shows its command, and `check` does not run it. A -catalogue Step shows `run-published-issue`. An -optional Step is marked `(optional)`. `check` starts no session. +of Steps that run at the same time. A throwaway worktree is described as +throwaway at the Base branch. An Issue-branch worktree is described as an +Issue branch. A skill Step shows the skill and where it was found. A command +Step shows its command, and `check` does not run it. A delivery Step shows +`delivery`. A `run-published-issue` Step shows `run-published-issue`. An +optional Step is marked `(optional)`. `check` starts no session. `check +issue ` resolves the issue and still creates no Issue branch and makes +no Claim. Without an issue, an Issue-branch worktree is the refusal above, not +a plan. Running the Process starts the Steps in its throwaway worktrees, detached at the Base branch and cleaned up as a standalone skill's worktree is. Every throwaway worktree is removed at the end, including after a failure, unless uncommitted changes, commits absent from every remote-tracking branch, or -uncertain ownership keep it. The Base branch is the branch checked out in the -launch directory, at its commit on origin. The launch checkout stays untouched. +uncertain ownership keep it. The Issue-branch worktree is a real checkout of +the Issue branch, beside the launch repository, and is removed at the end +when its work is on origin, as a Ticket run's is. The Base branch is the +branch checked out in the launch directory, at its commit on origin, or the +open pull request's base in a Continuation. The launch checkout stays +untouched. Every session Step uses the Command's Harness, Model and Effort: the User config default, or the Harness default when that is unset. A command Step does @@ -1006,14 +1074,19 @@ that Step instead of starting the session. The Command log names the cause. `check` and running refuse a missing file, a file that is not valid TOML, a missing or wrong `version`, an unknown key, a Step without exactly one of `skill`, `command` and `catalogue`, a command that is not a string or is empty, -a catalogue value that is not the string `run-published-issue`, a +a catalogue value other than `delivery` or `run-published-issue`, a `run-published-issue` Step that does not directly follow a session Step, an `optional` value that is not a boolean, a skill that cannot be found, an unknown worktree, a Step that does not name a worktree when the Process -declares them, parallel Steps that share a worktree, a worktree whose kind is -not `throwaway`, and a reserved name (`check`, `list`, `ticket-run`, -`spec-run`, `architect-run`, `pickup-run`, `security-run`). The refusal names -the rule and the file line, and starts no session. +declares them, parallel Steps that share a worktree, a worktree kind other +than `throwaway` or `issue-branch`, a second Issue-branch worktree, a delivery +Step with no Issue-branch worktree or on a different worktree, a second +delivery Step, an optional delivery Step, a delivery Step in a parallel group, +an Issue-branch worktree with no delivery Step after the last Step that uses +it, an Issue-branch worktree with no issue, and a reserved name (`check`, +`list`, `ticket-run`, `spec-run`, `architect-run`, `pickup-run`, +`security-run`). The refusal names the rule and the file line, and starts no +session. The Command is recorded as kind `Process`, with the process name. Its Command log has one line per Step: the Step's name, duration, outcome and cause. A @@ -1032,7 +1105,9 @@ notification under the same `email`, `email-unless-merged` and `no-email` flags and the same User config rules as a Ticket run. A missing address or Resend key stops the Process before any Step. A skipped Process sends none. -A Custom process started with no issue is a [Pass](#one-at-a-time). While another Pass on the repository is still running, its Spec run or Ticket run included, `thirdshift process ` is skipped: it prints `another Pass is already running on /`, prints nothing on stdout, exits `0` and sends no Run notification. It creates no worktree and starts no Step. While it runs, a Pickup run on the same repository is skipped. It does not take an Architect run's Ready-issue or Architect-idea gates, so a Ready issue or an Architect idea waiting for triage does not skip it. The Command record's outcome is `skipped`, and its cause is that reason. The Activity log records `Process skipped: ` when that is not already the last Process line. With `activity.quiet_skips` set, a skipped Process prints nothing. `thirdshift process issue ` is not a Pass: this rule does not skip it, and it does not hold the lock. +A Custom process started with no issue is a [Pass](#one-at-a-time), except one +that declares an Issue-branch worktree: that is refused, as above, rather than +skipped. While another Pass on the repository is still running, its Spec run or Ticket run included, `thirdshift process ` is skipped: it prints `another Pass is already running on /`, prints nothing on stdout, exits `0` and sends no Run notification. It creates no worktree and starts no Step. While it runs, a Pickup run on the same repository is skipped. It does not take an Architect run's Ready-issue or Architect-idea gates, so a Ready issue or an Architect idea waiting for triage does not skip it. The Command record's outcome is `skipped`, and its cause is that reason. The Activity log records `Process skipped: ` when that is not already the last Process line. With `activity.quiet_skips` set, a skipped Process prints nothing. `thirdshift process issue ` is not a Pass: this rule does not skip it, and it does not hold the lock. ### User config @@ -1684,7 +1759,7 @@ The rule doesn't ask whether anything will take the Ready issue. A `ready-for-ag A **Pass** is a command started with no Issue URL that decides before any work whether it is skipped. Only one Pass per repository runs at a time on a machine. A Pass started while another on the same repository is still running, the Spec run or Ticket run it dispatched included, is **skipped**: it prints `another Pass is already running on /` as a progress line on stderr, prints nothing on stdout, and exits `0`. Skipped is not a failure. A skipped Pass does nothing else: no launch pull, no worktree, no agent session, and no issue filed or labelled. This holds for every Pass, however it was started, so two never build one repository at once. -The repository is the one `origin` names, so two clones of one repository count as the same, and Passes on different repositories run at the same time. A Ticket run or a Spec run started on an **Issue URL** is never skipped this way, and never makes a Pass skip. A Custom process started with an issue is the same. A Custom process started with no issue is a Pass for this rule only: it does not take an Architect run's Ready-issue or Architect-idea gates. Its skip keeps a Command record whose outcome is `skipped`. +The repository is the one `origin` names, so two clones of one repository count as the same, and Passes on different repositories run at the same time. A Ticket run or a Spec run started on an **Issue URL** is never skipped this way, and never makes a Pass skip. A Custom process started with an issue is the same. A Custom process started with no issue, and whose file declares no Issue-branch worktree, is a Pass for this rule only: it does not take an Architect run's Ready-issue or Architect-idea gates. Its skip keeps a Command record whose outcome is `skipped`. A file that declares an Issue-branch worktree is refused when no issue is given, before any session, rather than skipped. thirdshift holds the rule with an operating-system lock on a file under `~/.thirdshift/architect-locks/`, shared by every kind of Pass, which the Pass's process takes without waiting, after its checks in step 1, and holds until it exits. The directory keeps its original name so older thirdshift versions share the lock too. The operating system releases the lock when that process ends for any reason, a crash, a kill or a reboot included, so there is never a stale lock to clear: the next Pass on the repository runs normally. The file itself stays, and means nothing on its own. A review worktree that a killed Architect run left behind is removed by the next one. diff --git a/site/architecture/index.html b/site/architecture/index.html index 5b96b267d..866b0f7fe 100644 --- a/site/architecture/index.html +++ b/site/architecture/index.html @@ -478,8 +478,8 @@

Architecture

Figure 5 · thirdshift pickup, architect, secure

The Passes: the factory finds its own work

Before selecting a Ready issue, Pickup files one ordinary bug Ticket when the Base branch's latest push CI failed more than 30 minutes ago and no CI-red Ticket is open. Concurrent hosts reserve filing on origin. CI read failures warn and leave Pickup running; this check does not hold Self-merges.

-

Once a Pickup run, an Architect run or a Security run knows its repository, it replaces that kind's last-fired time under the repository's log folder, before launch checks or the local Pass lock. Repeated skips update the time without adding Activity log lines. thirdshift status, thirdshift cronjobs and their Console views read the same per-repository times; no reader starts work. A Custom process started with no issue shares the lock and does not record a last-fired time.

-

A Pass takes no Issue URL, so your operating system's scheduler can start it; thirdshift never schedules itself. Only one Pass runs on a repository at a time, and a Pass with nothing to do is skipped, not failed. Architect and Security runs wait while a Ready issue is open, so work you shaped goes first. A Custom process started with no issue is a Pass for that one-at-a-time rule and does not wait on a Ready issue or an Architect idea. One started with an issue is not a Pass. Your scheduler can start Architect runs every few minutes and Security runs on a schedule, with fixing allowed.

+

Once a Pickup run, an Architect run or a Security run knows its repository, it replaces that kind's last-fired time under the repository's log folder, before launch checks or the local Pass lock. Repeated skips update the time without adding Activity log lines. thirdshift status, thirdshift cronjobs and their Console views read the same per-repository times; no reader starts work. A Custom process started with no issue, and no Issue-branch worktree, shares the lock and does not record a last-fired time. A file that declares an Issue-branch worktree is refused when no issue is given, before the lock.

+

A Pass takes no Issue URL, so your operating system's scheduler can start it; thirdshift never schedules itself. Only one Pass runs on a repository at a time, and a Pass with nothing to do is skipped, not failed. Architect and Security runs wait while a Ready issue is open, so work you shaped goes first. A Custom process started with no issue is a Pass for that one-at-a-time rule and does not wait on a Ready issue or an Architect idea, unless the file declares an Issue-branch worktree: that is refused before any session, rather than skipped. One started with an issue is not a Pass. Your scheduler can start Architect runs every few minutes and Security runs on a schedule, with fixing allowed.

The three PassesThe operating system’s scheduler starts three kinds of Pass, each taking no Issue URL. A Pickup run sweeps closed issues, respects the Claim limit and runs the lowest-numbered Ready issue by default. An Architect run reviews the architecture and either publishes a plan, which it builds with a Ticket run or Spec run, or files an idea for the Day shift to triage. A Security run audits the Base branch, reproduces findings, records each privately and, when fixing is allowed, files a fix issue and runs it. Its target code may run only inside an OS-enforced sandbox, as the Security audit skill requires. diff --git a/site/docs/index.html b/site/docs/index.html index 5647f01e5..97396b50d 100644 --- a/site/docs/index.html +++ b/site/docs/index.html @@ -64,7 +64,7 @@
  • Pickup run, thirdshift pickup: takes the lowest-numbered Ready issue by default, the highest-numbered with pickup.order = "highest-first" or highest-first on the command, and runs it.
  • Security run, thirdshift secure: audits the Base branch and records each Security finding privately. It is report-only by default; with fixing allowed, it fixes one finding it reproduced.
  • -

    Architect, Pickup and Security runs are Passes, as is a Custom process started with no issue: they take no Issue URL, so your operating system's scheduler can start them. A Custom process started with an issue is not a Pass. See A Pickup run on a schedule, Architect runs every few minutes for Architect runs, and Security runs on a schedule, with fixing allowed for Security runs.

    +

    Architect, Pickup and Security runs are Passes, as is a Custom process started with no issue and no Issue-branch worktree: they take no Issue URL, so your operating system's scheduler can start them. A Custom process started with an issue is not a Pass. A file that declares an Issue-branch worktree is refused when no issue is given, before any session, rather than skipped. See A Pickup run on a schedule, Architect runs every few minutes for Architect runs, and Security runs on a schedule, with fixing allowed for Security runs.

    Every session runs on the Harness you choose: Claude Code by default, or Codex, Antigravity CLI, Grok Build, Muse Code or OpenCode. See Harness, Model and Effort.

    What a Ticket run does

    @@ -465,7 +465,7 @@ thirdshift skill <skill> [issue <issue>] # run one skill headlessly and print its final message thirdshift process # list Custom processes (experimental) thirdshift process <name> [issue <issue>] # run a Custom process, optionally on an issue; email flags match a Ticket run (experimental) -thirdshift process check <name> # validate one and print its plan (experimental) +thirdshift process check <name> [issue <issue>] # validate one and print its plan (experimental) thirdshift score # sample merged factory pull requests (see Scoring runs) thirdshift scores [<pull request>] [--json] # show Scores and calibration thirdshift scores <pull request> --mine [--json] # enter your five Scores @@ -767,8 +767,9 @@

    Custom processes

    A Custom process runs Steps in order, and can run consecutive Steps together. -A Step is a skill session, a command, or a catalogue Step. It is experimental. Write -~/.thirdshift/processes/<name>.toml. The name is the file's stem.

    +A Step is a skill session, a command, or a catalogue Step. It is +experimental. Write ~/.thirdshift/processes/<name>.toml. The name is the +file's stem.

    version = 1
     description = "Gather notes, then review them"
     
    @@ -827,6 +828,55 @@
     skill = "thirdshift-to-spec"
     worktree = "judge"
     
    +

    One worktree may instead be an Issue branch, and one Step may publish it:

    +
    version = 1
    +description = "Ship a fix"
    +
    +[worktrees.fix]
    +kind = "issue-branch"
    +
    +[[steps]]
    +name = "implement"
    +skill = "thirdshift-implement"
    +worktree = "fix"
    +
    +[[steps]]
    +name = "ship"
    +catalogue = "delivery"
    +worktree = "fix"
    +
    +
    thirdshift process check ship issue 7
    +thirdshift process ship issue 7
    +
    +

    A declared worktree's kind is throwaway or issue-branch. A throwaway +worktree is detached at the Base branch. An Issue-branch worktree is the +Issue branch a Ticket run would use for the issue the command names: +issue-<n>, then issue-<n>-branch-<k> for k ≥ 2. A Continuation reuses +the existing branch, and an open pull request's base is the Base branch. +Session Steps in that checkout are told to build on its commits. A +Process has at most one Issue-branch worktree. The Steps that name it run in +that checkout. Any other declared worktree stays throwaway.

    +

    catalogue = "delivery" publishes that Issue branch. It is Delivery with no +opening session and no Security review: push, open a draft pull request when +the branch has none, write the Built-with line, mark the pull request ready +for review, and keep it mergeable with green or absent CI through the Repair +loop. It does not Self-merge, even when the User config asks Ticket runs to. +When thirdshift opens the pull request, its title is the issue's and its body +starts Closes #<n>. Repairs run on the Command's Harness, Model and Effort. +A Process has at most one delivery Step. It names the Issue-branch worktree, +it is not optional, and it is not in a parallel group. It comes after the +last Step that uses that worktree.

    +

    An Issue-branch worktree needs an issue. thirdshift process <name> and +thirdshift process check <name> refuse the file before any session, any +Claim and any branch, naming the issue rule and the file line. With an +issue, the run makes the Claim when it starts, labelling the issue +in-progress in place of ready-for-agent, and holds it until the end. A +pull request left ready for review removes the Claim and does not put +ready-for-agent back, so Pickup does not take an issue that already has an +open pull request. A Step that fails before Delivery keeps the Issue-branch +work as a Failed ticket run does, and the failure says where it stopped, what +was preserved and the command thirdshift process <name> issue <url>. The +Claim stays when that work reached origin, and is released when it did not.

    version must be 1. Each Step has a name and exactly one kind. skill is looked up the same way as thirdshift skill. First a Factory skill whose directory is exactly that name. Then the repository's committed @@ -841,22 +891,21 @@ under its own name, as a copy written out beside those skills, and kept out of git. A repository skill of the same name is not replaced. command is a command or script, run with sh -c in the Step's worktree. It starts no session. catalogue names a -built-in Step. The only one is run-published-issue. It starts a Ticket run, or a -Spec run when the issue has Tickets, on the issue the session Step before it -published. That session's final message ends with the issue's URL on its last -non-blank line. Before it starts a run, thirdshift checks that the issue is in -this repository, which it checks before viewing the issue, that the issue is -open, that a Trusted account published it, and that it was created since that -session Step began. A final message without that URL, or an issue that fails a -check, fails the Step, names the reason, and starts no run. The child run is -part of this Command. Its Tickets, when the issue is a Spec, run at once up to -spec.parallel in the User config. The child run's outcome is the Step's -outcome, and the Step's result is the pull request's URL. The run uses the -Process's Base branch and the Command's Harness. run-published-issue is the -Step immediately after a session Step, and it does not share that Step's -parallel group. A declared -worktree's kind must be throwaway: it is detached at the Base branch. When -the Process declares worktrees, every Step names one with worktree. +built-in Step: delivery, described above, or run-published-issue. +run-published-issue starts a Ticket run, or a Spec run when the issue has +Tickets, on the issue the session Step before it published. That session's final +message ends with the issue's URL on its last non-blank line. Before it starts a +run, thirdshift checks that the issue is in this repository, which it checks +before viewing the issue, that the issue is open, that a Trusted account +published it, and that it was created since that session Step began. A final +message without that URL, or an issue that fails a check, fails the Step, names +the reason, and starts no run. The child run is part of this Command. Its +Tickets, when the issue is a Spec, run at once up to spec.parallel in the User +config. The child run's outcome is the Step's outcome, and the Step's result is +the pull request's URL. The run uses the Process's Base branch and the +Command's Harness. run-published-issue is the Step immediately after a session +Step, and it does not share that Step's parallel group. When the Process +declares worktrees, every Step names one with worktree. Consecutive Steps with the same parallel name run at the same time. Every session or command Step edits the worktree it runs in, so Steps in one group cannot share a worktree. The same group name after a Step that is not in the @@ -865,23 +914,33 @@ failure stops the Process once the other Steps in its group have finished.

    thirdshift process
     thirdshift process check gather-review
    +thirdshift process check ship issue 7
     thirdshift process gather-review
    +thirdshift process ship issue 7
     

    thirdshift process lists each Custom process and its description. check validates the file and prints the plan: the description, the Command's Harness, Model and Effort, and each Step. A Process with no named worktrees prints one throwaway worktree at the Base branch. A Process that declares worktrees prints each one, the worktree each Step uses, and a together: line for each group -of Steps that run at the same time. A skill Step shows the skill and where it -was found. A command Step shows its command, and check does not run it. A -catalogue Step shows run-published-issue. An -optional Step is marked (optional). check starts no session.

    +of Steps that run at the same time. A throwaway worktree is described as +throwaway at the Base branch. An Issue-branch worktree is described as an +Issue branch. A skill Step shows the skill and where it was found. A command +Step shows its command, and check does not run it. A delivery Step shows +delivery. A run-published-issue Step shows run-published-issue. An +optional Step is marked (optional). check starts no session. check <name> issue <issue> resolves the issue and still creates no Issue branch and makes +no Claim. Without an issue, an Issue-branch worktree is the refusal above, not +a plan.

    Running the Process starts the Steps in its throwaway worktrees, detached at the Base branch and cleaned up as a standalone skill's worktree is. Every throwaway worktree is removed at the end, including after a failure, unless uncommitted changes, commits absent from every remote-tracking branch, or -uncertain ownership keep it. The Base branch is the branch checked out in the -launch directory, at its commit on origin. The launch checkout stays untouched.

    +uncertain ownership keep it. The Issue-branch worktree is a real checkout of +the Issue branch, beside the launch repository, and is removed at the end +when its work is on origin, as a Ticket run's is. The Base branch is the +branch checked out in the launch directory, at its commit on origin, or the +open pull request's base in a Continuation. The launch checkout stays +untouched.

    Every session Step uses the Command's Harness, Model and Effort: the User config default, or the Harness default when that is unset. A command Step does not. Each finished Step's result is stored beside the Command log, one file @@ -916,14 +975,19 @@

    check and running refuse a missing file, a file that is not valid TOML, a missing or wrong version, an unknown key, a Step without exactly one of skill, command and catalogue, a command that is not a string or is empty, -a catalogue value that is not the string run-published-issue, a +a catalogue value other than delivery or run-published-issue, a run-published-issue Step that does not directly follow a session Step, an optional value that is not a boolean, a skill that cannot be found, an unknown worktree, a Step that does not name a worktree when the Process -declares them, parallel Steps that share a worktree, a worktree whose kind is -not throwaway, and a reserved name (check, list, ticket-run, -spec-run, architect-run, pickup-run, security-run). The refusal names -the rule and the file line, and starts no session.

    +declares them, parallel Steps that share a worktree, a worktree kind other +than throwaway or issue-branch, a second Issue-branch worktree, a delivery +Step with no Issue-branch worktree or on a different worktree, a second +delivery Step, an optional delivery Step, a delivery Step in a parallel group, +an Issue-branch worktree with no delivery Step after the last Step that uses +it, an Issue-branch worktree with no issue, and a reserved name (check, +list, ticket-run, spec-run, architect-run, pickup-run, +security-run). The refusal names the rule and the file line, and starts no +session.

    The Command is recorded as kind Process, with the process name. Its Command log has one line per Step: the Step's name, duration, outcome and cause. A command that exits non-zero is failed, and the line names its exit status. @@ -939,7 +1003,9 @@ notification under the same email, email-unless-merged and no-email flags and the same User config rules as a Ticket run. A missing address or Resend key stops the Process before any Step. A skipped Process sends none.

    -

    A Custom process started with no issue is a Pass. While another Pass on the repository is still running, its Spec run or Ticket run included, thirdshift process <name> is skipped: it prints another Pass is already running on <owner>/<repo>, prints nothing on stdout, exits 0 and sends no Run notification. It creates no worktree and starts no Step. While it runs, a Pickup run on the same repository is skipped. It does not take an Architect run's Ready-issue or Architect-idea gates, so a Ready issue or an Architect idea waiting for triage does not skip it. The Command record's outcome is skipped, and its cause is that reason. The Activity log records Process <name> skipped: <reason> when that is not already the last Process line. With activity.quiet_skips set, a skipped Process prints nothing. thirdshift process <name> issue <issue> is not a Pass: this rule does not skip it, and it does not hold the lock.

    +

    A Custom process started with no issue is a Pass, except one +that declares an Issue-branch worktree: that is refused, as above, rather than +skipped. While another Pass on the repository is still running, its Spec run or Ticket run included, thirdshift process <name> is skipped: it prints another Pass is already running on <owner>/<repo>, prints nothing on stdout, exits 0 and sends no Run notification. It creates no worktree and starts no Step. While it runs, a Pickup run on the same repository is skipped. It does not take an Architect run's Ready-issue or Architect-idea gates, so a Ready issue or an Architect idea waiting for triage does not skip it. The Command record's outcome is skipped, and its cause is that reason. The Activity log records Process <name> skipped: <reason> when that is not already the last Process line. With activity.quiet_skips set, a skipped Process prints nothing. thirdshift process <name> issue <issue> is not a Pass: this rule does not skip it, and it does not hold the lock.

    User config

    @@ -1462,7 +1528,7 @@

    One at a time

    A Pass is a command started with no Issue URL that decides before any work whether it is skipped. Only one Pass per repository runs at a time on a machine. A Pass started while another on the same repository is still running, the Spec run or Ticket run it dispatched included, is skipped: it prints another Pass is already running on <owner>/<repo> as a progress line on stderr, prints nothing on stdout, and exits 0. Skipped is not a failure. A skipped Pass does nothing else: no launch pull, no worktree, no agent session, and no issue filed or labelled. This holds for every Pass, however it was started, so two never build one repository at once.

    -

    The repository is the one origin names, so two clones of one repository count as the same, and Passes on different repositories run at the same time. A Ticket run or a Spec run started on an Issue URL is never skipped this way, and never makes a Pass skip. A Custom process started with an issue is the same. A Custom process started with no issue is a Pass for this rule only: it does not take an Architect run's Ready-issue or Architect-idea gates. Its skip keeps a Command record whose outcome is skipped.

    +

    The repository is the one origin names, so two clones of one repository count as the same, and Passes on different repositories run at the same time. A Ticket run or a Spec run started on an Issue URL is never skipped this way, and never makes a Pass skip. A Custom process started with an issue is the same. A Custom process started with no issue, and whose file declares no Issue-branch worktree, is a Pass for this rule only: it does not take an Architect run's Ready-issue or Architect-idea gates. Its skip keeps a Command record whose outcome is skipped. A file that declares an Issue-branch worktree is refused when no issue is given, before any session, rather than skipped.

    thirdshift holds the rule with an operating-system lock on a file under ~/.thirdshift/architect-locks/, shared by every kind of Pass, which the Pass's process takes without waiting, after its checks in step 1, and holds until it exits. The directory keeps its original name so older thirdshift versions share the lock too. The operating system releases the lock when that process ends for any reason, a crash, a kill or a reboot included, so there is never a stale lock to clear: the next Pass on the repository runs normally. The file itself stays, and means nothing on its own. A review worktree that a killed Architect run left behind is removed by the next one.

    A skipped Architect run sends no Run notification, even when asked, and neither does a skipped Pickup run (sends none). Both make the notification's checks before the lock is tried all the same, so a broken setup fails the pass with exit 1 rather than being skipped.

    diff --git a/site/how-it-works/index.html b/site/how-it-works/index.html index 82e6b7f8a..b5d173148 100644 --- a/site/how-it-works/index.html +++ b/site/how-it-works/index.html @@ -211,7 +211,7 @@

    How thirdshift works

    A direct Issue URL starts work you have already chosen. Other commands find work, then dispatch the same Ticket run or Spec run you just followed. Each invocation makes one pass; your operating system's scheduler starts the next one.

    To run one skill directly, use thirdshift skill <skill> [issue <issue>]. It starts one headless session on your default Harness, Model and Effort in a temporary worktree at your checkout's committed HEAD, and prints the final message. Worktrees with local work are kept. Standalone skills.

    -

    thirdshift process is experimental, beside thirdshift skill. It runs the Steps in ~/.thirdshift/processes/<name>.toml, a skill session, a command, or the catalogue Step run-published-issue, which starts a Ticket run or a Spec run on the issue the session Step before it published. Consecutive Steps can run together, each in a throwaway worktree the file names, and the last finished Step's result is printed. An optional Step can fail without stopping the process. thirdshift process <name> issue <issue> gives the process an issue, so each session Step works from that issue. With no issue it is a Pass and skips while another Pass on the repository is running, without an Architect run's Ready-issue or Architect-idea gates. With an issue, it is not a Pass. Status, the Console and the Activity log list it as Process <name>, and a failed one is in Needs you. It sends a Run notification under the same email rules as a Ticket run, except a skipped Process sends none. Custom processes.

    +

    thirdshift process is experimental, beside thirdshift skill. It runs the Steps in ~/.thirdshift/processes/<name>.toml, a skill session, a command, or a catalogue Step. delivery publishes the Issue branch. run-published-issue starts a Ticket run or a Spec run on the issue the session Step before it published. Consecutive Steps can run together, each in a throwaway worktree the file names, and the last finished Step's result is printed. An optional Step can fail without stopping the process. thirdshift process <name> issue <issue> gives the process an issue, so each session Step works from that issue. One Issue-branch worktree plus delivery publishes a ready-for-review pull request from that Issue branch and then removes the Claim. With no issue, and no Issue-branch worktree, it is a Pass and skips while another Pass on the repository is running, without an Architect run's Ready-issue or Architect-idea gates. With an issue, it is not a Pass. Status, the Console and the Activity log list it as Process <name>, and a failed one is in Needs you. It sends a Run notification under the same email rules as a Ticket run, except a skipped Process sends none. Custom processes.

    Different entry points, the same implementation and Delivery
    diff --git a/site/index.html b/site/index.html index e96d6e189..7280f437b 100644 --- a/site/index.html +++ b/site/index.html @@ -91,7 +91,7 @@

    Your issues become pull requests overnight.

    1. /thirdshift-implement
    2. /thirdshift-code-review
    3. /thirdshift-pr

    thirdshift takes an Issue URL: one issue starts a Ticket run; an issue with sub-issues starts a Spec run. Its Factory skills, adapted to run with no human in the loop, implement the issue with /thirdshift-implement, review the work with /thirdshift-code-review and write the pull request with /thirdshift-pr.

    Issues labelled bug first use /thirdshift-diagnosing-bugs to reproduce the reported failure and write a failing regression test before fixing it. If it cannot reproduce the bug, the Ticket run leaves triage notes and needs-info on the issue, ending successfully without a new pull request. Security fixes and Base fixes keep their own flow.

    -

    thirdshift skill runs one skill. thirdshift process is experimental: it runs skill, command or catalogue Steps from ~/.thirdshift/processes, including Steps that run together in throwaway worktrees the file names. The catalogue Step run-published-issue starts a Ticket run or a Spec run on the issue the session Step before it published. An optional Step can fail without stopping the process. Pass issue to give the process an issue, so each session Step works from that issue. With no issue it is a Pass: it skips, exiting 0 with no Run notification, while another Pass on the repository is running, and it does not take an Architect run's Ready-issue or Architect-idea gates. With an issue, it is not a Pass. Status, the Console and the Activity log list it as Process <name>, and a failed one is in Needs you. It sends a Run notification under the same email rules as a Ticket run, except a skipped Process sends none. Custom processes.

    +

    thirdshift skill runs one skill. thirdshift process is experimental: it runs skill, command or catalogue Steps from ~/.thirdshift/processes, including Steps that run together in throwaway worktrees the file names. The catalogue Step run-published-issue starts a Ticket run or a Spec run on the issue the session Step before it published. An optional Step can fail without stopping the process. Pass issue to give the process an issue, so each session Step works from that issue. A file can name one Issue-branch worktree and a delivery Step: the run holds the Claim, and Delivery leaves a ready-for-review pull request and then removes the Claim. With no issue, and no Issue-branch worktree, it is a Pass: it skips, exiting 0 with no Run notification, while another Pass on the repository is running, and it does not take an Architect run's Ready-issue or Architect-idea gates. With an issue, it is not a Pass. Status, the Console and the Activity log list it as Process <name>, and a failed one is in Needs you. It sends a Run notification under the same email rules as a Ticket run, except a skipped Process sends none. Custom processes.

    Read every prompt and skill the agents get →

    @@ -355,7 +355,7 @@

    Six workflows

    An Architect run looks for architecture improvements and builds its top Strong plan. Without a Strong recommendation, it leaves an Architect idea for the Day shift. Use --plan-only to publish a plan and stop.

    A Pickup run takes the lowest-numbered Ready issue by default, the highest-numbered with pickup.order = "highest-first" or highest-first, after it has settled and while the repository is below its Claim limit. It also files one ordinary bug Ticket when the Base branch's latest push CI has stayed red for more than 30 minutes. Failed work stays with the Day shift until you hand it back by applying ready-for-agent again.

    A Security run audits the Base branch and records findings privately. It is report-only by default. With security-fix, it can fix a reproduced finding through a Ticket run or Spec run. With security-close, it can close a finding its fix merged, once its proof-of-concept test passes against the merged Base branch. An optional Security review checks each Ticket run's change, including Tickets and the Spec PR, and holds Self-merge if introduced findings remain unaddressed or the review is incomplete. Its Harness is checked before work; an unavailable review Harness falls back to the default Harness, with one fallback also allowed for a Harness error before its first turn.

    -

    Start passes with thirdshift architect base main, thirdshift pickup base main or thirdshift secure base main. A Custom process started with no issue follows the same one-at-a-time rule and does not wait on a Ready issue or an Architect idea. Your operating system schedules them. thirdshift status, thirdshift cronjobs and the Console show when each repository's Pickup, Architect and Security Pass last fired, including repeated skips. Architect runs every few minutes and Security runs on a schedule, with fixing allowed. Both yield to work the Day shift shaped, and pause when a decision needs you.

    +

    Start passes with thirdshift architect base main, thirdshift pickup base main or thirdshift secure base main. A Custom process started with no issue, and no Issue-branch worktree, follows the same one-at-a-time rule and does not wait on a Ready issue or an Architect idea. A file that declares an Issue-branch worktree is refused when no issue is given, before any session. Your operating system schedules them. thirdshift status, thirdshift cronjobs and the Console show when each repository's Pickup, Architect and Security Pass last fired, including repeated skips. Architect runs every few minutes and Security runs on a schedule, with fixing allowed. Both yield to work the Day shift shaped, and pause when a decision needs you.

    The Console opens on this machine's Inbox: Commands grouped by what needs you, with their stages and logs. It reads ready rows' pull requests and Needs you rows' issues and pull requests from GitHub in the background once a minute; changed answers update open pages automatically. thirdshift status uses the same kept answer. Failed reads keep the last facts; after five minutes, both show when GitHub last answered and why the latest read failed. With fewer than 500 GraphQL points or REST requests left, both wait for the quota to reset, leaving the rest to the factory. An ended Needs you row leaves when GitHub shows its issue closed or its pull request merged; a Triage row also leaves when needs-triage is removed. A Finding leaves with “findings triaged” once all its private records are triaged on GitHub. A draft advisory without severity, or an open security-finding issue labelled needs-triage, still waits; a finding closed by its merged fix does not. Live Silent and Slow rows wait until their Commands end. A signed-in account can Stop or Requeue a live Command, just as from the CLI. On an Inbox row or in Command details, Dismiss gives a row in Needs you or Ready for review a Dismissal in one click; Undo takes it back. Command details shows each CLI form: thirdshift dismiss <command> or thirdshift dismiss <command> --undo. A dismissed row returns for a new kind of problem or when it ends ready for review; each kind counts once for the whole row.

    Above its groups, the Inbox counts the runs active on this machine. The Console's Resources page, also available with thirdshift resources, shows host memory, CPU load and the User config limits that can delay new sessions.

    Experiments compare a Candidate with a Baseline on private Eval suites. thirdshift experiment <candidate> builds both Arms and runs interleaved Trials with two workers, a fixed Bar and resume after interruption or a plan-limit warning. Trials and Graders run in bwrap sandboxes. Code Graders score mechanical checks offline from report packets and the prepared repository, then fresh model Graders score written criteria from blind packets with provider-only access. Graders cannot read raw Trial logs, Arm mappings or the Experiment’s other files. Results show the Verdict, wrong findings, borderline marks, paired intervals, each Arm's minutes, tokens by Model, reported cost and Void causes. Trials and Graders join the Command's Trace. Void Trials retry fresh up to twice; an exhausted task drops out of both Arms. More than a tenth dropped makes the Verdict inconclusive.

    diff --git a/src/args.rs b/src/args.rs index d92f09ad5..2270a9001 100644 --- a/src/args.rs +++ b/src/args.rs @@ -20,7 +20,10 @@ use crate::run::Goal; /// What `thirdshift process` was asked to do. pub enum ProcessAction { List, - Check(String), + Check { + name: String, + issue: Option, + }, Run { name: String, issue: Option, @@ -837,7 +840,7 @@ fn metrics(args: &[String]) -> Result { Ok(asked) } -const PROCESS_USAGE: &str = "usage: thirdshift process [check | [issue ] [email [address] | email-unless-merged [address] | no-email]]"; +const PROCESS_USAGE: &str = "usage: thirdshift process [check [issue ] | [issue ] [email [address] | email-unless-merged [address] | no-email]]"; fn parse_process(args: &[String]) -> Result { if args.is_empty() { @@ -860,7 +863,7 @@ fn parse_process(args: &[String]) -> Result { continue; } if arg == "issue" { - if check || name.is_none() || expect_issue || issue.is_some() { + if name.is_none() || expect_issue || issue.is_some() { bail!(PROCESS_USAGE); } expect_issue = true; @@ -880,8 +883,8 @@ fn parse_process(args: &[String]) -> Result { bail!(PROCESS_USAGE); } match (check, name) { - (true, Some(name)) if issue.is_none() => Ok(ProcessAction::Check(name)), - (true, _) => bail!(PROCESS_USAGE), + (true, Some(name)) => Ok(ProcessAction::Check { name, issue }), + (true, None) => bail!(PROCESS_USAGE), (false, Some(name)) => Ok(ProcessAction::Run { name, issue, email }), (false, None) => bail!(PROCESS_USAGE), } diff --git a/src/claim.rs b/src/claim.rs index 3f6e8ae75..744fbefb1 100644 --- a/src/claim.rs +++ b/src/claim.rs @@ -5,7 +5,9 @@ //! issue closed, kept while its pull request waits for review, and released //! when the run failed with nothing on origin to take over, except a failed //! Security fix, which stays with the Day shift. A Stop instead leaves the -//! issue for triage, or explicitly releases it when asked to requeue. +//! issue for triage, or explicitly releases it when asked to requeue. A +//! Custom process's Delivery removes it when the pull request is ready for +//! review, without putting `ready-for-agent` back. //! //! What it reads and changes of GitHub and origin, whether the run is //! interrupted, and its progress lines all go through [`Outside`]: @@ -99,6 +101,20 @@ impl Claim<'_> { reached, ); } + + /// Take `in-progress` off the issue, whether or not it is open, and put + /// nothing back. A Delivery that left a pull request ready for review + /// ends this way: the issue is not Ready, so Pickup does not take it + /// again while that pull request is open. A failure is a warning naming + /// what to run by hand. + pub fn remove(self) { + let launch = self.launch.completion(); + self.claimed.remove(&mut OnGitHub { + launch: &launch, + github: GitHub::new().completion(), + branch: &self.branch, + }); + } } /// What the Claim reads and changes of its issue on GitHub and of origin, @@ -382,6 +398,34 @@ impl<'a> Claimed<'a> { (off, on) } + /// Take `in-progress` off, open issue or not, and put nothing on. An issue + /// that is no longer `in-progress` is left as it is. + fn remove(&self, outside: &mut impl Outside) { + if let Err(error) = retry_if_interrupted(outside, |outside| self.take_off(outside)) { + outside.warn( + &error, + format!( + "could not remove {IN_PROGRESS} from issue #{}, so remove it by hand: {}", + self.issue.number, + labels::by_hand(self.issue, &[IN_PROGRESS], &[]) + ), + ); + } + } + + /// [`Claimed::remove`], its failure as `gh` gave it. + fn take_off(&self, outside: &mut impl Outside) -> Result<()> { + let edit = Edit::of(self.issue, outside.labels(self.issue)?, &[IN_PROGRESS], &[]); + if !edit.takes_off(IN_PROGRESS) { + return Ok(()); + } + outside.step(format!( + "removing {IN_PROGRESS} from issue #{}", + self.issue.number + )); + outside.apply(&edit) + } + /// Remove the Claim if the Self-merge has left its issue closed: /// `in-progress` comes off, in one request that keeps the issue's other /// labels. An issue still open, as when closing it failed, keeps it, and diff --git a/src/delivery.rs b/src/delivery.rs index 256e4eb76..d4e896bca 100644 --- a/src/delivery.rs +++ b/src/delivery.rs @@ -130,75 +130,231 @@ impl Delivery<'_> { checklist: Option<&str>, ) -> Result { let tracker = Tracker::new(opening.session.stage(), CommandLog); - let route = Route { - security_review: self.security_review, - issue: self.issue, - base: self.base, - branch: worktree.branch(), - goal: self.goal, - harness: &self.child_asks.harness, - }; - pull_request.begin_delivery(); - let (issue, started, path) = (self.issue, self.started, worktree.path()); - let before_each = move || { - no_untrusted_edit_since(issue, started)?; - hand_over_issue(issue, path) - }; - let (delivered, log) = Sessions::checked_within( - self.logs, - worktree.path(), - &self.child_asks.harness, - self.child_asks.security_review_harness.as_ref(), - self.child_asks.security_review_default.as_ref(), - &before_each, - |sessions| { + let logs = self.logs; + let issue = self.issue; + let started = self.started; + let base = self.base; + let goal = self.goal; + let security_review = self.security_review; + let child_asks = self.child_asks; + let base_fix = self.base_fix; + run_checked( + CheckedRun { + logs, + issue, + started, + harness: &child_asks.harness, + security_review: child_asks.security_review_harness.as_ref(), + security_review_default: child_asks.security_review_default.as_ref(), + base, + }, + worktree, + pull_request, + &tracker, + |resolution, log| Reached { + resolution, + log, + ticket_lines: Vec::new(), + }, + |merged, log| Reached { + resolution: Resolution::PullRequest { + url: merged.pr.url.clone(), + goal: Goal::Merged, + }, + log, + ticket_lines: Vec::new(), + }, + |tracker, worktree, pull_request, sessions| { + let route = Route { + security_review, + issue, + base, + branch: worktree.branch(), + goal, + harness: &child_asks.harness, + }; let mut outside = InWorktree { - tracker: &tracker, - issue: self.issue, - worktree: &worktree, - base: self.base, - base_fix: self.base_fix, - child_asks: self.child_asks, + tracker, + issue, + worktree, + base, + base_fix, + child_asks, sessions, pull_request, }; route.steps(&mut outside, &opening, checklist) }, - ); - let result = match delivered { - Ok(resolution) => Ok(Reached { - resolution, - log, - ticket_lines: Vec::new(), - }), - Err(error) => { - if let Some(merged) = error.downcast_ref::() - && worktree.fully_landed_at(&merged.head).unwrap_or(false) - { - progress::step("the pull request was merged while Delivery was running"); - Ok(Reached { - resolution: Resolution::PullRequest { - url: merged.pr.url.clone(), - goal: Goal::Merged, - }, - log, - ticket_lines: Vec::new(), - }) - } else { - let stopped_at = stopped_at(&tracker, &error); - let path = worktree.path().to_path_buf(); - let mut outside = OnFailure { - base: self.base, - worktree, - pull_request, - }; - Err(fail(&mut outside, stopped_at, path, log, error)) - } + ) + } + + /// Publish the Issue branch already produced by a Custom process and + /// leave its pull request ready for review. There is no opening session + /// and no Security review: the Process's Steps did the work. Push, open + /// a draft pull request when the branch has none, write the Built-with + /// line, mark it ready, and keep it mergeable and its CI green through + /// the Repair loop. The goal is ready for review, so this does not + /// Self-merge. Repairs run on [`Delivery::child_asks`]'s Harness. Any + /// failure goes through the Failed run path. + pub fn publish( + self, + worktree: Worktree, + pull_request: &mut PullRequest, + ) -> Result { + let tracker = Tracker::new(Stage::Push, CommandLog); + let logs = self.logs; + let issue = self.issue; + let started = self.started; + let base = self.base; + let child_asks = self.child_asks; + let base_fix = self.base_fix; + run_checked( + CheckedRun { + logs, + issue, + started, + harness: &child_asks.harness, + security_review: child_asks.security_review_harness.as_ref(), + security_review_default: child_asks.security_review_default.as_ref(), + base, + }, + worktree, + pull_request, + &tracker, + |url, _log| url, + |merged, _log| merged.pr.url.clone(), + |tracker, worktree, pull_request, sessions| { + publish_steps(&mut InWorktree { + tracker, + issue, + worktree, + base, + base_fix, + child_asks, + sessions, + pull_request, + }) + }, + ) + } +} + +/// What the shared Delivery envelope checks before each session and names +/// when a failure preserves the work: the issue, when the run began, where +/// its sessions are logged, the harnesses they run on, and the Base branch. +struct CheckedRun<'a> { + logs: &'a Logs, + issue: &'a IssueUrl, + started: DateTime, + harness: &'a Choice, + security_review: Option<&'a Choice>, + security_review_default: Option<&'a Choice>, + base: &'a str, +} + +/// The shared Delivery envelope: check for an untrusted edit before each +/// session, run `steps`, and on failure either accept an already-merged pull +/// request whose head includes the local work or take the Failed run path. +fn run_checked( + checked: CheckedRun<'_>, + worktree: Worktree, + pull_request: &mut PullRequest, + tracker: &Tracker, + on_ok: impl FnOnce(T, Option) -> S, + on_merged: impl FnOnce(&AlreadyMerged, Option) -> S, + steps: impl FnOnce(&Tracker, &Worktree, &mut PullRequest, &Sessions) -> Result, +) -> Result { + let CheckedRun { + logs, + issue, + started, + harness, + security_review, + security_review_default, + base, + } = checked; + pull_request.begin_delivery(); + let path = worktree.path().to_path_buf(); + let before_each = move || { + no_untrusted_edit_since(issue, started)?; + hand_over_issue(issue, &path) + }; + let (delivered, log) = Sessions::checked_within( + logs, + worktree.path(), + harness, + security_review, + security_review_default, + &before_each, + |sessions| steps(tracker, &worktree, pull_request, sessions), + ); + let result = match delivered { + Ok(value) => Ok(on_ok(value, log)), + Err(error) => { + if let Some(merged) = error.downcast_ref::() + && worktree.fully_landed_at(&merged.head).unwrap_or(false) + { + progress::step("the pull request was merged while Delivery was running"); + Ok(on_merged(merged, log)) + } else { + let stopped_at = stopped_at(tracker, &error); + let path = worktree.path().to_path_buf(); + let mut outside = OnFailure { + base, + worktree, + pull_request, + }; + Err(fail(&mut outside, stopped_at, path, log, error)) } - }; - crate::logs::unaddressed_findings(pull_request.unaddressed_findings()); - result + } + }; + crate::logs::unaddressed_findings(pull_request.unaddressed_findings()); + result +} + +/// [`Delivery::publish`]'s steps: push, open a draft when none exists, write +/// what it was built with, mark the pull request ready, and take it to ready +/// for review through the Repair loop. +fn publish_steps(outside: &mut InWorktree<'_>) -> Result { + outside.tracker.enter(Stage::Push); + outside.pull_request.ensure_actionable(false)?; + outside + .worktree + .push_if_actionable(&mut || outside.pull_request.ensure_actionable(false))?; + let title = GitHub::new().issue_title(outside.issue)?; + let body = format!("Closes #{}", outside.issue.number); + outside.pull_request.open_if_absent(&title, &body)?; + if let Err(error) = outside + .pull_request + .write_built_with(&outside.child_asks.harness) + { + progress::warn( + &error, + format_args!("could not write the Built-with line in the pull request's body"), + ); } + outside.tracker.enter(Stage::PullRequestReadiness); + let tracker = outside.tracker; + let issue = outside.issue; + let worktree = outside.worktree; + let base = outside.base; + let child_asks = outside.child_asks; + let sessions = outside.sessions; + let pr = outside.pull_request.mark_ready(None)?; + let mut repair = RunOutside { + tracker, + issue, + worktree, + base, + pr_url: &pr.url, + base_fix: outside.base_fix, + child_asks, + sessions, + pull_request: outside.pull_request, + ci_watch: ci::Watch::new(issue), + }; + repair_loop::take_to_goal(&mut repair, base, Goal::ReadyForReview)?; + Ok(pr.url) } /// The steps of a Delivery of the pull request for `issue`, from `branch` @@ -420,16 +576,8 @@ fn fail( let preservation = if keep_ready { Preservation::Remote } else { - match outside.preserve_failed_run(&reason) { - Ok(Preserved::Pushed) => Preservation::Remote, - Ok(Preserved::NoChanges) => Preservation::None, - Err(problem) => { - outside.step(format!( - "could not push the failed ticket run's work, so it may exist only locally: {problem:#}" - )); - Preservation::Local - } - } + let preserved = outside.preserve_failed_run(&reason); + recovery::preservation_of(preserved, |line| outside.step(line)) }; let pr_url = match outside.finish_pr(keep_ready) { Ok(pr_url) => pr_url, diff --git a/src/main.rs b/src/main.rs index f4fbf9b30..df1afeadd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -92,8 +92,8 @@ usage: thirdshift Run the factory on the iss thirdshift secure base Use as the Base branch thirdshift skill [issue ] Run one skill and print its final message thirdshift process List Custom processes (experimental) - thirdshift process [issue ] Run a Custom process; email flags match a Ticket run (experimental) - thirdshift process check Validate a Custom process and print its plan (experimental) + thirdshift process [issue ] Run a Custom process, optionally on an issue; email flags match a Ticket run (experimental) + thirdshift process check [issue ] Validate a Custom process and print its plan (experimental) thirdshift score Sample merged factory work and propose Bug origins thirdshift scores [] [--mine] [--json] Show Scores; --mine enters yours thirdshift bug-origins [] [--json] Show origins; --confirm / --correct / --none settles one diff --git a/src/processes.rs b/src/processes.rs index 34c19afce..76c19b8f6 100644 --- a/src/processes.rs +++ b/src/processes.rs @@ -3,11 +3,18 @@ //! //! Steps run in order. Consecutive Steps that name the same `parallel` group //! run at the same time, each in a throwaway worktree the Process declares. +//! One worktree may be an Issue branch. The Steps that use it are the editing +//! Steps, and a `delivery` Step after the last of them publishes that branch +//! and leaves a pull request ready for review. The catalogue Step +//! `run-published-issue` starts a Ticket run, or a Spec run, on the issue the +//! session Step before it published. //! -//! A Process started with no issue is a Pass. It takes the repository's Pass -//! lock and is skipped, before any worktree, while another Pass holds it. It -//! does not take an Architect run's Ready-issue or Architect-idea gates. A -//! Process started with an issue is not a Pass and does not take the lock. +//! A Process started with no issue is a Pass, unless it declares an +//! Issue-branch worktree, which needs an issue. A Pass takes the repository's +//! Pass lock and is skipped, before any worktree, while another Pass holds +//! it. It does not take an Architect run's Ready-issue or Architect-idea +//! gates. A Process started with an issue is not a Pass and does not take the +//! lock. use std::collections::BTreeSet; use std::fmt::Write; @@ -24,6 +31,10 @@ use toml_edit::{Document, Item, Key, Table}; use crate::args::ProcessAction; use crate::asks::{Asks, Flags}; +use crate::base_fix::{BaseFix, BaseFixAsk}; +use crate::branch::{self, Selection}; +use crate::child_run::ChildAsks; +use crate::claim::{self, Claim}; use crate::config::UserConfig; use crate::git::Git; use crate::github::GitHub; @@ -32,9 +43,12 @@ use crate::issue::{IssueInput, IssueUrl}; use crate::logs::{self, Begin, Work}; use crate::notification::{About, NotificationAsk, RunNotification}; use crate::process::{self, Control, Interruption}; +use crate::pull_request::PullRequest; +use crate::recovery::{self, Recovery}; +use crate::run::Goal; use crate::run_ending::{Account, Cause}; use crate::skill; -use crate::worktree::SkillWorktree; +use crate::worktree::{SkillWorktree, Worktree}; use crate::{delivery, interrupt, launch, progress, prompt, session, skills, trust}; /// The issue URL when the Process has an issue; unset otherwise. @@ -62,6 +76,8 @@ struct ProcessFile { struct WorktreeDraft { name: String, + /// The `kind` line when this worktree is an Issue branch. + issue_branch_line: Option, } struct StepDraft { @@ -76,7 +92,13 @@ struct StepDraft { enum DraftKind { Skill { skill: String, line: usize }, Command { command: String }, - Catalogue { line: usize }, + Delivery { line: usize }, + RunPublishedIssue { line: usize }, +} + +struct DeclaredWorktree { + name: String, + issue_branch: bool, } struct ResolvedStep { @@ -90,6 +112,7 @@ struct ResolvedStep { enum StepKind { Skill { name: String, path: PathBuf }, Command(String), + Delivery, RunPublishedIssue, } @@ -100,7 +123,9 @@ struct OwnedWorktree { struct Prepared { worktrees: Vec, - declared: Vec, + declared: Vec, + /// The Issue-branch worktree's name, when the file declares one. + issue_worktree: Option, _written: WrittenGuard, choice: Choice, description: String, @@ -112,16 +137,62 @@ struct Prepared { base: String, } +/// The Issue branch a run is delivering, beside the throwaway worktrees. +struct IssueRun<'a> { + checkout: &'a mut Option, + issue: &'a IssueUrl, + branch: &'a str, + base: &'a str, + on_origin: bool, + logs: &'a session::Logs, + base_fix: &'a mut BaseFix, + process: &'a str, +} + +/// Ends the Claim on drop unless the run removed it or ended it already. +struct HeldClaim<'a> { + claim: Option>, +} + +impl HeldClaim<'_> { + fn remove(&mut self) { + if let Some(claim) = self.claim.take() { + claim.remove(); + } + } + + fn release(&mut self) { + if let Some(claim) = self.claim.take() { + claim.end(None); + } + } +} + +impl Drop for HeldClaim<'_> { + fn drop(&mut self) { + self.release(); + } +} + /// What one Step needs from the Process, shared by a parallel group. struct StepRun<'a> { process: &'a str, choice: &'a Choice, issue: Option<&'a IssueUrl>, started: DateTime, + /// Branch and Base branch when this Step continues an Issue branch. + continuation: Option<(String, String)>, + /// The Base branch checked out in the Launch directory. base: &'a str, steps: &'a [ResolvedStep], } +/// Results and start times of Steps that have already finished. +struct PriorSteps<'a> { + results: &'a [(String, PathBuf)], + began: &'a [(String, DateTime)], +} + struct WrittenGuard; impl Drop for WrittenGuard { @@ -133,7 +204,7 @@ impl Drop for WrittenGuard { pub fn execute(action: ProcessAction) -> Result { match action { ProcessAction::List => list(), - ProcessAction::Check(name) => check(&name), + ProcessAction::Check { name, issue } => check(&name, issue), ProcessAction::Run { name, issue, email } => run(&name, issue, email), } } @@ -176,10 +247,27 @@ fn list() -> Result { Ok(lines.join("\n")) } -fn check(name: &str) -> Result { +fn check(name: &str, issue: Option) -> Result { interrupt::install()?; let config = UserConfig::load()?; - let prepared = prepare(name, &config)?; + let (path, file) = load(name)?; + let issue = resolve_process_issue(issue)?; + require_issue(&path, &file, issue.as_ref())?; + let base = launch::checked_out_base()?; + let launch = Git::new(std::env::current_dir()?); + let prepared = prepare_from( + &path, + &config, + file, + issue, + Utc::now(), + WorktreeSource { + git: &launch, + commit: base.origin_commit(), + issue_checkout: None, + base: base.name(), + }, + )?; Ok(plan(name, &prepared)) } @@ -190,7 +278,7 @@ fn run(name: &str, issue: Option, email: Option) -> // address or Resend key fails the Process before it starts. As for the // other Passes, the check is before a skip, so a Process that would be // skipped fails on it too. - let mut notification = RunNotification::new( + let notification = RunNotification::new( Flags { email, ..Flags::default() @@ -199,14 +287,22 @@ fn run(name: &str, issue: Option, email: Option) -> &config.email, About::Process { name }, )?; + let (path, file) = match load(name) { + Ok(loaded) => loaded, + Err(error) => return Err(failed_before_steps(notification, error)), + }; // Before any worktree: a foreign Issue URL is an origin mismatch, and a - // number that cannot be resolved stops the same way. A failure here is - // still a failed Process, so it sends the Run notification a Ticket run - // would send for the same kind of stop. + // number that cannot be resolved stops the same way. An Issue-branch + // worktree with no issue is refused before the Pass log. A failure here + // is still a failed Process, so it sends the Run notification a Ticket + // run would send for the same kind of stop. let issue = match resolve_process_issue(issue) { Ok(issue) => issue, Err(error) => return Err(failed_before_steps(notification, error)), }; + if let Err(error) = require_issue(&path, &file, issue.as_ref()) { + return Err(failed_before_steps(notification, error)); + } // No issue: a Pass. The Base branch is checked before the lock, so a bad // checkout fails instead of being skipped, and a skip creates no worktree. // The lock is then held until this process exits, through every Step. @@ -215,10 +311,11 @@ fn run(name: &str, issue: Option, email: Option) -> if issue.is_none() { logs::begin(Begin::Process { name, pass: true }); logs::configured(&config); - let repo = match (|| { - let _base = launch::checked_out_base()?; - launch::repo() - })() { + let base = match launch::checked_out_base() { + Ok(base) => base, + Err(error) => return Err(failed_before_steps(notification, error)), + }; + let repo = match launch::repo() { Ok(repo) => repo, Err(error) => return Err(failed_before_steps(notification, error)), }; @@ -233,33 +330,65 @@ fn run(name: &str, issue: Option, email: Option) -> Ok(None) => {} Err(error) => return Err(failed_before_steps(notification, error)), } + let started = Utc::now() - crate::run::STARTED_MARGIN; + let launch = match std::env::current_dir() { + Ok(dir) => Git::new(dir), + Err(error) => return Err(failed_before_steps(notification, error.into())), + }; + let prepared = match prepare_from( + &path, + &config, + file, + None, + started, + WorktreeSource { + git: &launch, + commit: base.origin_commit(), + issue_checkout: None, + base: base.name(), + }, + ) { + Ok(prepared) => prepared, + Err(error) => return Err(failed_before_steps(notification, error)), + }; + return finish_run(name, &repo, prepared, notification); } - // The same margin as a Ticket run. Edits in the gap since this moment, - // or dated by a slow GitHub clock, still count. + let issue = issue.expect("an issue"); let started = Utc::now() - crate::run::STARTED_MARGIN; - let mut prepared = match prepare(name, &config) { + if file_has_issue_branch(&file) { + return run_issue_branch(name, &config, &path, file, issue, started, notification); + } + let base = match launch::checked_out_base() { + Ok(base) => base, + Err(error) => return Err(failed_before_steps(notification, error)), + }; + let launch = match std::env::current_dir() { + Ok(dir) => Git::new(dir), + Err(error) => return Err(failed_before_steps(notification, error.into())), + }; + let prepared = match prepare_from( + &path, + &config, + file, + Some(issue), + started, + WorktreeSource { + git: &launch, + commit: base.origin_commit(), + issue_checkout: None, + base: base.name(), + }, + ) { Ok(prepared) => prepared, Err(error) => return Err(failed_before_steps(notification, error)), }; - prepared.issue = issue; - prepared.started = started; let repo = match launch::repo() { Ok(repo) => repo, Err(error) => return Err(failed_before_steps(notification, error)), }; - if prepared.issue.is_some() { - logs::begin(Begin::Process { name, pass: false }); - logs::configured(&config); - } - logs::started(Work::Process { repo: &repo, name }, &prepared.choice); - notification.built_with(&prepared.choice); - let result = execute_steps(name, &prepared); - let (outcome, cause) = classify(&result); - drop(prepared); - logs::ended(outcome); - logs::complete(outcome, cause.as_deref()); - notify(notification, outcome, cause.as_deref()); - result + logs::begin(Begin::Process { name, pass: false }); + logs::configured(&config); + finish_run(name, &repo, prepared, notification) } /// A stop after the email check and before any Step is still a failed Process. @@ -301,12 +430,152 @@ fn notify(notification: RunNotification, outcome: &'static str, cause: Option<&s notification.finish(&account); } -fn execute_steps(process: &str, prepared: &Prepared) -> Result { +fn finish_run( + name: &str, + repo: &crate::issue::Repo, + prepared: Prepared, + mut notification: RunNotification, +) -> Result { + logs::started(Work::Process { repo, name }, &prepared.choice); + notification.built_with(&prepared.choice); + let result = execute_steps(name, &prepared, None); + let (outcome, cause) = classify(&result); + drop(prepared); + logs::ended(outcome); + logs::complete(outcome, cause.as_deref()); + notify(notification, outcome, cause.as_deref()); + result +} + +fn run_issue_branch( + name: &str, + config: &UserConfig, + path: &Path, + file: ProcessFile, + issue: IssueUrl, + started: DateTime, + mut notification: RunNotification, +) -> Result { + let base_branch = match launch::checked_out_base() { + Ok(base) => base, + Err(error) => return Err(failed_before_steps(notification, error)), + }; + let launch_git = match std::env::current_dir() { + Ok(dir) => Git::new(dir), + Err(error) => return Err(failed_before_steps(notification, error.into())), + }; + let selection = match branch::select(&launch_git, &GitHub::new(), &issue) { + Ok(selection) => selection, + Err(error) => return Err(failed_before_steps(notification, error)), + }; + let base = selection + .pr_base(None, Some(base_branch.name()), |line| progress::step(&line)) + .unwrap_or(base_branch.name()) + .to_string(); + let on_origin = matches!(selection, Selection::Continuation { .. }); + let branch = selection.branch().to_string(); + let made = match claim::make(&issue, &launch_git, &branch) { + Ok(claim) => claim, + Err(error) => return Err(failed_before_steps(notification, error)), + }; + let mut claim = HeldClaim { claim: Some(made) }; + let checkout = match &selection { + Selection::Fresh { .. } => Worktree::create_fresh(&launch_git, &issue.repo, &branch, &base), + Selection::Continuation { .. } => { + Worktree::continue_existing(&launch_git, &issue.repo, &branch, &base) + } + }; + let checkout = match checkout { + Ok(checkout) => checkout, + Err(error) => return Err(failed_before_steps(notification, error)), + }; + let commit = if base == base_branch.name() { + base_branch.origin_commit().to_string() + } else { + match launch_git.run(&["rev-parse", &format!("origin/{base}")]) { + Ok(commit) => commit, + Err(error) => return Err(failed_before_steps(notification, error)), + } + }; + let prepared = match prepare_from( + path, + config, + file, + Some(issue.clone()), + started, + WorktreeSource { + git: &launch_git, + commit: &commit, + issue_checkout: Some(checkout.path()), + base: base_branch.name(), + }, + ) { + Ok(prepared) => prepared, + Err(error) => return Err(failed_before_steps(notification, error)), + }; + let repo = match launch::repo() { + Ok(repo) => repo, + Err(error) => return Err(failed_before_steps(notification, error)), + }; + let mut held = Some(checkout); + // Session traces ask for the log root, which exists once this command is + // configured. The Command record stays the Process; the traces are named + // for the issue. + logs::begin(Begin::Process { name, pass: false }); + logs::configured(config); + let logs = session::Logs::of_run(&issue); + let mut base_fix = BaseFix::new(None, BaseFixAsk::Forbid); + logs::started(Work::Process { repo: &repo, name }, &prepared.choice); + notification.built_with(&prepared.choice); + let result = { + let mut issue_run = IssueRun { + checkout: &mut held, + issue: &issue, + branch: &branch, + base: &base, + on_origin, + logs: &logs, + base_fix: &mut base_fix, + process: name, + }; + let result = execute_steps(name, &prepared, Some(&mut issue_run)); + let context = continuation(&issue_run); + let kept_on_origin = issue_run.on_origin; + if let Err(error) = &result + && let Some(checkout) = issue_run.checkout.as_mut() + { + preserve_step_failure(checkout, context, kept_on_origin, error); + } + result + }; + if result.is_ok() { + claim.remove(); + } else { + claim.release(); + } + let (outcome, cause) = classify(&result); + drop(prepared); + drop(held); + logs::ended(outcome); + logs::complete(outcome, cause.as_deref()); + notify(notification, outcome, cause.as_deref()); + result +} + +fn execute_steps( + process: &str, + prepared: &Prepared, + mut issue_run: Option<&mut IssueRun<'_>>, +) -> Result { let adapter = prepared.choice.harness.adapter(); for worktree in &prepared.worktrees { skills::link_into(worktree.checkout.path(), adapter)?; adapter.link_instruction_fallback(worktree.checkout.path())?; } + if let Some(checkout) = issue_run.as_ref().and_then(|run| run.checkout.as_ref()) { + skills::link_into(checkout.path(), adapter)?; + adapter.link_instruction_fallback(checkout.path())?; + } let results = logs::command_log_path() .context("a Process has no Command log")? .with_extension("steps"); @@ -317,6 +586,29 @@ fn execute_steps(process: &str, prepared: &Prepared) -> Result { let mut last = String::new(); for range in group_ranges(&prepared.steps) { let group = &prepared.steps[range.clone()]; + if group.len() == 1 && matches!(group[0].kind, StepKind::Delivery) { + let Some(issue_run) = issue_run.as_mut() else { + bail!("delivery has no Issue-branch worktree"); + }; + let started = Instant::now(); + let began_at = Utc::now(); + let outcome = run_delivery(prepared, &group[0], issue_run); + finish_step( + &group[0], + started.elapsed(), + (began_at, outcome), + &results, + &mut earlier, + &mut began, + &mut last, + )?; + continue; + } + let held = issue_run.as_deref(); + let prior = PriorSteps { + results: &earlier, + began: &began, + }; let outcomes = if group.len() == 1 { let started = Instant::now(); let began_at = Utc::now(); @@ -324,10 +616,10 @@ fn execute_steps(process: &str, prepared: &Prepared) -> Result { process, prepared, &group[0], - &earlier, - &began, + &prior, &results, range.start == 0, + held, ); vec![(started.elapsed(), began_at, outcome)] } else { @@ -335,10 +627,10 @@ fn execute_steps(process: &str, prepared: &Prepared) -> Result { process, prepared, group, - &earlier, - &began, + &prior, &results, range.start, + held, ) }; let mut group_error = None; @@ -394,37 +686,44 @@ fn run_parallel( process: &str, prepared: &Prepared, group: &[ResolvedStep], - earlier: &[(String, PathBuf)], - began: &[(String, DateTime)], + prior: &PriorSteps<'_>, results: &Path, group_start: usize, + issue_run: Option<&IssueRun<'_>>, ) -> Vec<(Duration, DateTime, Result)> { + let (checkout, continuing) = held_issue_branch(issue_run); let steps = &prepared.steps; let base = prepared.base.as_str(); thread::scope(|scope| { let mut handles = Vec::with_capacity(group.len()); for (offset, step) in group.iter().enumerate() { - let cwd = worktree_path(&prepared.worktrees, step.worktree.as_deref()).to_path_buf(); + let cwd = step_dir(prepared, step, checkout).to_path_buf(); let choice = prepared.choice.clone(); - let earlier = earlier.to_vec(); - let began = began.to_vec(); + let earlier = prior.results.to_vec(); + let began = prior.began.to_vec(); let process = process.to_string(); let results = results.to_path_buf(); let first = group_start + offset == 0; let issue = prepared.issue.clone(); let started_at = prepared.started; + let continuation = step_continuation(prepared, step, continuing.as_ref()); handles.push(scope.spawn(move || { let started = Instant::now(); let began_at = Utc::now(); + let prior = PriorSteps { + results: &earlier, + began: &began, + }; let run = StepRun { process: &process, choice: &choice, issue: issue.as_ref(), started: started_at, + continuation, base, steps, }; - let outcome = run_step_in(&run, &cwd, step, &earlier, &began, &results, first); + let outcome = run_step_in(&run, &cwd, step, &prior, &results, first); (started.elapsed(), began_at, outcome) })); } @@ -477,42 +776,163 @@ fn run_step( process: &str, prepared: &Prepared, step: &ResolvedStep, - earlier: &[(String, PathBuf)], - began: &[(String, DateTime)], + prior: &PriorSteps<'_>, results: &Path, first: bool, + issue_run: Option<&IssueRun<'_>>, ) -> Result { + let (checkout, continuing) = held_issue_branch(issue_run); let run = StepRun { process, choice: &prepared.choice, issue: prepared.issue.as_ref(), started: prepared.started, + continuation: step_continuation(prepared, step, continuing.as_ref()), base: &prepared.base, steps: &prepared.steps, }; run_step_in( &run, - worktree_path(&prepared.worktrees, step.worktree.as_deref()), + step_dir(prepared, step, checkout), step, - earlier, - began, + prior, results, first, ) } +fn step_dir<'a>( + prepared: &'a Prepared, + step: &ResolvedStep, + checkout: Option<&'a Worktree>, +) -> &'a Path { + if let Some(checkout) = checkout + && names_issue_worktree(step.worktree.as_deref(), prepared.issue_worktree.as_deref()) + { + return checkout.path(); + } + worktree_path(&prepared.worktrees, step.worktree.as_deref()) +} + +/// The Issue-branch checkout a run holds, and the branch names when that +/// branch already has commits on origin. +fn held_issue_branch<'a>( + issue_run: Option<&'a IssueRun<'_>>, +) -> (Option<&'a Worktree>, Option<(String, String)>) { + let Some(run) = issue_run else { + return (None, None); + }; + let continuing = run + .on_origin + .then(|| (run.branch.to_string(), run.base.to_string())); + (run.checkout.as_ref(), continuing) +} + +/// Branch and Base branch for a Step whose checkout continues the Issue branch. +fn step_continuation( + prepared: &Prepared, + step: &ResolvedStep, + continuing: Option<&(String, String)>, +) -> Option<(String, String)> { + let (branch, base) = continuing?; + names_issue_worktree(step.worktree.as_deref(), prepared.issue_worktree.as_deref()) + .then(|| (branch.clone(), base.clone())) +} + +fn names_issue_worktree(step: Option<&str>, issue: Option<&str>) -> bool { + matches!((step, issue), (Some(step), Some(issue)) if step == issue) +} + +fn run_delivery( + prepared: &Prepared, + step: &ResolvedStep, + issue_run: &mut IssueRun<'_>, +) -> Result { + let checkout = issue_run + .checkout + .take() + .context("the Issue-branch worktree is gone")?; + progress::step(format_args!( + "running step {} with {choice}", + step.name, + choice = prepared.choice + )); + let mut pull_request = PullRequest::new( + issue_run.issue, + issue_run.branch, + issue_run.base, + GitHub::new(), + ); + let child_asks = ChildAsks { + harness: prepared.choice.clone(), + ..ChildAsks::default() + }; + let started = prepared.started; + match (delivery::Delivery { + security_review: false, + child_asks: &child_asks, + issue: issue_run.issue, + base: issue_run.base, + goal: Goal::ReadyForReview, + base_fix: issue_run.base_fix, + logs: issue_run.logs, + started, + }) + .publish(checkout, &mut pull_request) + { + Ok(url) => Ok(url), + Err(mut failed) => { + if let Some(recovery) = &mut failed.recovery { + recovery.add_command_context(continuation(issue_run), issue_run.on_origin); + for line in recovery.lines() { + progress::step(line); + } + } + Err(failed.error) + } + } +} + +fn continuation(issue_run: &IssueRun<'_>) -> recovery::Continuation { + recovery::Continuation { + command: format!( + "thirdshift process {} issue {}", + issue_run.process, issue_run.issue.url + ), + launch: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + base: issue_run.base.to_string(), + branch: issue_run.branch.to_string(), + } +} + +fn preserve_step_failure( + checkout: &mut Worktree, + context: recovery::Continuation, + on_origin: bool, + error: &anyhow::Error, +) { + let reason = Cause::of(error).first_line().to_string(); + let preserved = checkout.preserve_failed_run(&context.base, &reason, &mut || Ok(())); + let preservation = recovery::preservation_of(preserved, progress::step); + let mut recovery = Recovery::new(recovery::Stage::Implementation, preservation); + recovery.record_worktree(checkout.path().to_path_buf()); + recovery.add_command_context(context, on_origin); + for line in recovery.lines() { + progress::step(line); + } +} + fn run_step_in( run: &StepRun<'_>, cwd: &Path, step: &ResolvedStep, - earlier: &[(String, PathBuf)], - began: &[(String, DateTime)], + prior: &PriorSteps<'_>, results: &Path, first: bool, ) -> Result { crate::resources::wait()?; match &step.kind { - StepKind::RunPublishedIssue => run_published(run, step, earlier, began), + StepKind::RunPublishedIssue => run_published(run, step, prior), StepKind::Skill { name, path } => { progress::step(format_args!( "running step {} with {choice}", @@ -524,9 +944,14 @@ fn run_step_in( &step.name, name, path, - earlier, - first, + prompt::EarlierSteps { + results: prior.results, + first, + }, run.issue, + run.continuation + .as_ref() + .map(|(branch, base)| prompt::ProcessContinuation { branch, base }), ); match run.issue { Some(issue) => { @@ -537,6 +962,7 @@ fn run_step_in( } } } + StepKind::Delivery => bail!("delivery cannot be in a parallel group"), StepKind::Command(command) => { progress::step(format_args!("running step {}", step.name)); let result_file = results.join(format!("{}.txt", step.name)); @@ -608,12 +1034,7 @@ fn run_skill_on_issue( /// Start a Ticket run, or a Spec run, on the issue the session Step before /// this one published. The child run is part of this Command. Its outcome /// is this Step's outcome. -fn run_published( - run: &StepRun<'_>, - step: &ResolvedStep, - earlier: &[(String, PathBuf)], - began: &[(String, DateTime)], -) -> Result { +fn run_published(run: &StepRun<'_>, step: &ResolvedStep, prior: &PriorSteps<'_>) -> Result { let index = run .steps .iter() @@ -622,7 +1043,7 @@ fn run_published( let Some(previous) = index.checked_sub(1).map(|index| &run.steps[index]) else { bail!("run-published-issue must directly follow a session Step"); }; - let issue = published_issue(previous, earlier)?; + let issue = published_issue(previous, prior.results)?; let origin = Git::new(std::env::current_dir()?).run(&["config", "remote.origin.url"])?; if !issue.matches_origin(&origin) { bail!( @@ -639,7 +1060,8 @@ fn run_published( if !trust::trusted_association(&association) { bail!("#{}'s author is not a Trusted account", issue.number); } - let started = began + let started = prior + .began .iter() .find(|(name, _)| name == &previous.name) .map(|(_, at)| *at) @@ -806,7 +1228,15 @@ fn plan(name: &str, prepared: &Prepared) -> String { chosen = prepared.choice.chosen_by, ); for worktree in &prepared.declared { - let _ = writeln!(text, "worktree {worktree}: throwaway at the Base branch"); + if worktree.issue_branch { + let _ = writeln!(text, "worktree {}: Issue branch", worktree.name); + } else { + let _ = writeln!( + text, + "worktree {}: throwaway at the Base branch", + worktree.name + ); + } } let named = !prepared.declared.is_empty(); for range in group_ranges(&prepared.steps) { @@ -833,6 +1263,7 @@ fn step_plan(step: &ResolvedStep, named: bool) -> String { let shown = command.replace(['\n', '\r'], " "); format!("{}: command {shown}", step.name) } + StepKind::Delivery => format!("{}: delivery", step.name), StepKind::RunPublishedIssue => format!("{}: run-published-issue", step.name), }; if named && let Some(worktree) = &step.worktree { @@ -844,47 +1275,102 @@ fn step_plan(step: &ResolvedStep, named: bool) -> String { line } -fn prepare(name: &str, config: &UserConfig) -> Result { +fn load(name: &str) -> Result<(PathBuf, ProcessFile)> { let path = process_path(name)?; refuse_reserved(&path, name)?; let text = read_process(&path)?; let file = parse(&path, &text)?; refuse_worktrees(&path, &file)?; + refuse_issue_branch(&path, &file)?; refuse_published(&path, &file.steps)?; - let base = launch::checked_out_base()?; - let launch = Git::new(std::env::current_dir()?); - let base_name = base.name().to_string(); - let commit = base.origin_commit(); - let written = WrittenGuard; - let worktrees = create_worktrees(&launch, commit, &file)?; + Ok((path, file)) +} + +/// The launch checkout a Process's worktrees are created from, and the +/// Issue-branch checkout a run already holds. +struct WorktreeSource<'a> { + git: &'a Git, + commit: &'a str, + /// Reuse this checkout for the Issue-branch worktree instead of creating it. + issue_checkout: Option<&'a Path>, + /// The Base branch checked out in the Launch directory. + base: &'a str, +} + +fn prepare_from( + path: &Path, + config: &UserConfig, + file: ProcessFile, + issue: Option, + started: DateTime, + source: WorktreeSource<'_>, +) -> Result { + let issue_worktree = file + .worktrees + .iter() + .find_map(|worktree| worktree.issue_branch_line.map(|_| worktree.name.clone())); + let declared = file + .worktrees + .iter() + .map(|worktree| DeclaredWorktree { + name: worktree.name.clone(), + issue_branch: worktree.issue_branch_line.is_some(), + }) + .collect(); + let skip = source.issue_checkout.and(issue_worktree.as_deref()); + let worktrees = create_worktrees(source.git, source.commit, &file, skip)?; let mut steps = Vec::new(); for step in &file.steps { - let checkout = worktree_path( + let checkout = resolve_dir( &worktrees, - step.worktree.as_ref().map(|(name, _)| name.as_str()), + step, + issue_worktree.as_deref(), + source.issue_checkout, ); - steps.push(resolve(&path, step, checkout)?); + steps.push(resolve(path, step, checkout)?); } let mut choice = Choice::of(&Asked::default(), &config.harness); choice.check_without_probe()?; Ok(Prepared { worktrees, - declared: file - .worktrees - .into_iter() - .map(|worktree| worktree.name) - .collect(), - _written: written, + declared, + issue_worktree, + _written: WrittenGuard, choice, description: file.description, steps, - issue: None, - started: Utc::now(), - base: base_name, + issue, + started, + base: source.base.to_string(), }) } -fn create_worktrees(launch: &Git, commit: &str, file: &ProcessFile) -> Result> { +fn resolve_dir<'a>( + worktrees: &'a [OwnedWorktree], + step: &StepDraft, + issue_worktree: Option<&str>, + issue_checkout: Option<&'a Path>, +) -> &'a Path { + if let Some(checkout) = issue_checkout + && names_issue_worktree( + step.worktree.as_ref().map(|(name, _)| name.as_str()), + issue_worktree, + ) + { + return checkout; + } + worktree_path( + worktrees, + step.worktree.as_ref().map(|(name, _)| name.as_str()), + ) +} + +fn create_worktrees( + launch: &Git, + commit: &str, + file: &ProcessFile, + skip: Option<&str>, +) -> Result> { if file.worktrees.is_empty() { return Ok(vec![OwnedWorktree { name: None, @@ -897,7 +1383,7 @@ fn create_worktrees(launch: &Git, commit: &str, file: &ProcessFile) -> Result Result<()> { Ok(()) } +fn file_has_issue_branch(file: &ProcessFile) -> bool { + file.worktrees + .iter() + .any(|worktree| worktree.issue_branch_line.is_some()) +} + +fn require_issue(path: &Path, file: &ProcessFile, issue: Option<&IssueUrl>) -> Result<()> { + if issue.is_some() { + return Ok(()); + } + let Some(worktree) = file + .worktrees + .iter() + .find(|worktree| worktree.issue_branch_line.is_some()) + else { + return Ok(()); + }; + Err(rule( + path, + worktree.issue_branch_line.expect("issue-branch line"), + "issue", + "an Issue-branch worktree needs an issue", + )) +} + +fn refuse_issue_branch(path: &Path, file: &ProcessFile) -> Result<()> { + let mut issue_branches = file + .worktrees + .iter() + .filter(|worktree| worktree.issue_branch_line.is_some()); + let Some(issue_branch) = issue_branches.next() else { + for step in &file.steps { + if let DraftKind::Delivery { line } = &step.kind { + return Err(rule( + path, + *line, + "delivery", + "delivery needs an Issue-branch worktree", + )); + } + } + return Ok(()); + }; + if let Some(second) = issue_branches.next() { + return Err(rule( + path, + second.issue_branch_line.expect("issue-branch line"), + "issue-branch", + "a Process has at most one Issue-branch worktree", + )); + } + let mut delivery_at = None; + let mut last_use = None; + for (index, step) in file.steps.iter().enumerate() { + if let DraftKind::Delivery { line } = &step.kind { + if delivery_at.is_some() { + return Err(rule( + path, + *line, + "delivery", + "a Process has at most one delivery Step", + )); + } + if step.optional { + return Err(rule(path, *line, "delivery", "delivery cannot be optional")); + } + if let Some((_, parallel_line)) = &step.parallel { + return Err(rule( + path, + *parallel_line, + "delivery", + "delivery cannot be in a parallel group", + )); + } + let uses = step + .worktree + .as_ref() + .is_some_and(|(name, _)| name == &issue_branch.name); + if !uses { + let line = step + .worktree + .as_ref() + .map(|(_, line)| *line) + .unwrap_or(*line); + return Err(rule( + path, + line, + "delivery", + "delivery must use the Issue-branch worktree", + )); + } + delivery_at = Some(index); + } else if step + .worktree + .as_ref() + .is_some_and(|(name, _)| name == &issue_branch.name) + { + last_use = Some(index); + } + } + match last_use { + Some(use_at) if delivery_at.is_some_and(|at| at > use_at) => Ok(()), + Some(use_at) => { + let step = &file.steps[use_at]; + let line = step + .worktree + .as_ref() + .map(|(_, line)| *line) + .unwrap_or(step.name_line); + Err(rule( + path, + line, + "delivery", + format!( + "no delivery Step after step {}, which uses the Issue-branch worktree", + step.name + ), + )) + } + None if delivery_at.is_some() => Ok(()), + None => Err(rule( + path, + issue_branch.issue_branch_line.expect("issue-branch line"), + "delivery", + "an Issue-branch worktree needs a delivery Step after the last Step that uses it", + )), + } +} + fn refuse_published(path: &Path, steps: &[StepDraft]) -> Result<()> { for (index, step) in steps.iter().enumerate() { - let DraftKind::Catalogue { line } = step.kind else { + let DraftKind::RunPublishedIssue { line } = step.kind else { continue; }; let follows = index.checked_sub(1).is_some_and(|previous| { @@ -1017,7 +1632,8 @@ fn resolve(path: &Path, step: &StepDraft, worktree: &Path) -> Result StepKind::Command(command.clone()), - DraftKind::Catalogue { .. } => StepKind::RunPublishedIssue, + DraftKind::Delivery { .. } => StepKind::Delivery, + DraftKind::RunPublishedIssue { .. } => StepKind::RunPublishedIssue, }; Ok(ResolvedStep { name: step.name.clone(), @@ -1158,6 +1774,7 @@ fn parse_worktrees( )); }; let mut saw_kind = false; + let mut issue_branch_line = None; for (field, value) in body.iter() { let field_line = key_line(text, body.key(field)); match field { @@ -1170,15 +1787,16 @@ fn parse_worktrees( "kind must be a string", )); }; - if kind != "throwaway" { + if kind != "throwaway" && kind != "issue-branch" { return Err(rule( path, field_line, "worktree kind", - "kind must be throwaway", + "kind must be throwaway or issue-branch", )); } saw_kind = true; + issue_branch_line = (kind == "issue-branch").then_some(field_line); } other => return Err(rule(path, field_line, "unknown key", other)), } @@ -1188,6 +1806,7 @@ fn parse_worktrees( } worktrees.push(WorktreeDraft { name: key.to_string(), + issue_branch_line, }); } Ok(worktrees) @@ -1261,9 +1880,15 @@ fn parse_step(path: &Path, text: &str, table_line: usize, table: &Table) -> Resu let line = key_line(text, table.key(key)); match key { "name" => name = Some(step_name(path, line, item)?), - "skill" => kinds.push(ParsedKind::Skill(step_skill(path, line, item)?)), - "command" => kinds.push(ParsedKind::Command(step_command(path, line, item)?)), - "catalogue" => kinds.push(ParsedKind::Catalogue(step_catalogue(path, line, item)?)), + "skill" => { + let (skill, line) = step_skill(path, line, item)?; + kinds.push((DraftKind::Skill { skill, line }, line)); + } + "command" => { + let (command, line) = step_command(path, line, item)?; + kinds.push((DraftKind::Command { command }, line)); + } + "catalogue" => kinds.push(step_catalogue(path, line, item)?), "worktree" => worktree = Some(step_worktree(path, line, item)?), "parallel" => parallel = Some(step_parallel(path, line, item)?), "optional" => optional = step_optional(path, line, item)?, @@ -1273,31 +1898,21 @@ fn parse_step(path: &Path, text: &str, table_line: usize, table: &Table) -> Resu let Some((name, name_line)) = name else { return Err(rule(path, table_line, "step name", "a step needs a name")); }; - let kind = match kinds.as_slice() { - [ParsedKind::Skill((skill, line))] => DraftKind::Skill { - skill: skill.clone(), - line: *line, - }, - [ParsedKind::Command((command, _))] => DraftKind::Command { - command: command.clone(), - }, - [ParsedKind::Catalogue(line)] => DraftKind::Catalogue { line: *line }, - [] => { - return Err(rule( - path, - name_line, - "exactly one kind", - format!("step {name} has no kind"), - )); - } - [_, second, ..] => { - return Err(rule( - path, - kind_line(second), - "exactly one kind", - format!("step {name} has more than one kind"), - )); - } + if kinds.len() > 1 { + return Err(rule( + path, + kinds[1].1, + "exactly one kind", + format!("step {name} has more than one kind"), + )); + } + let Some((kind, _)) = kinds.pop() else { + return Err(rule( + path, + name_line, + "exactly one kind", + format!("step {name} has no kind"), + )); }; Ok(StepDraft { name, @@ -1309,40 +1924,6 @@ fn parse_step(path: &Path, text: &str, table_line: usize, table: &Table) -> Resu }) } -enum ParsedKind { - Skill((String, usize)), - Command((String, usize)), - Catalogue(usize), -} - -fn kind_line(kind: &ParsedKind) -> usize { - match kind { - ParsedKind::Skill((_, line)) - | ParsedKind::Command((_, line)) - | ParsedKind::Catalogue(line) => *line, - } -} - -fn step_catalogue(path: &Path, line: usize, item: &Item) -> Result { - let Some(value) = item.as_str() else { - return Err(rule( - path, - line, - "exactly one kind", - "catalogue must be a string", - )); - }; - if value != "run-published-issue" { - return Err(rule( - path, - line, - "exactly one kind", - "catalogue must be run-published-issue", - )); - } - Ok(line) -} - fn step_worktree(path: &Path, line: usize, item: &Item) -> Result<(String, usize)> { let Some(value) = item.as_str() else { return Err(rule(path, line, "worktree", "worktree must be a string")); @@ -1405,6 +1986,30 @@ fn step_skill(path: &Path, line: usize, item: &Item) -> Result<(String, usize)> Ok((value.to_string(), line)) } +fn step_catalogue(path: &Path, line: usize, item: &Item) -> Result<(DraftKind, usize)> { + let Some(value) = item.as_str() else { + return Err(rule( + path, + line, + "exactly one kind", + "catalogue must be a string", + )); + }; + let kind = match value { + "delivery" => DraftKind::Delivery { line }, + "run-published-issue" => DraftKind::RunPublishedIssue { line }, + _ => { + return Err(rule( + path, + line, + "catalogue", + "catalogue must be delivery or run-published-issue", + )); + } + }; + Ok((kind, line)) +} + fn step_command(path: &Path, line: usize, item: &Item) -> Result<(String, usize)> { let Some(value) = item.as_str() else { return Err(rule( diff --git a/src/prompt.rs b/src/prompt.rs index c948dfb13..533adfb89 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -109,22 +109,46 @@ pub fn skill(name: &str, path: &std::path::Path, issue: Option<&IssueUrl>) -> St ) } -/// One Step of a Custom process. `earlier` is each finished Step's name and -/// the path of its final message, outside the worktree. `first` is whether -/// this is the Process's first Step. A later Step with no finished result -/// does not claim to be the first. `issue`, when the Process has one, is -/// named for the session to work from its file. +/// Branch and Base branch of a Step that continues an existing Issue branch. +pub struct ProcessContinuation<'a> { + pub branch: &'a str, + pub base: &'a str, +} + +/// Finished Steps named in a later Step's prompt, and whether that Step is +/// the Process's first. A later Step with no finished result is not the first. +pub struct EarlierSteps<'a> { + pub results: &'a [(String, std::path::PathBuf)], + pub first: bool, +} + +/// The sentence a Continuation uses so the session builds on existing commits. +fn continuing_work(branch: &str, base: &str) -> String { + format!( + "You are continuing work on branch {branch}, which already has commits (see git log {base}..HEAD). Build on them; don't start over." + ) +} + +/// One Step of a Custom process. `earlier` names each finished Step and +/// whether this is the Process's first Step. `issue`, when the Process has +/// one, is named for the session to work from its file. `continuation`, when +/// the Step's checkout is an existing Issue branch, tells the session to +/// build on that branch's commits. pub fn process_step( process: &str, step: &str, skill_name: &str, skill_path: &std::path::Path, - earlier: &[(String, std::path::PathBuf)], - first: bool, + earlier: EarlierSteps<'_>, issue: Option<&IssueUrl>, + continuation: Option>, ) -> String { - let mut earlier_text = if earlier.is_empty() { - if first { + let continuing = continuation + .as_ref() + .map(|note| format!("{}\n", continuing_work(note.branch, note.base))) + .unwrap_or_default(); + let mut earlier_text = if earlier.results.is_empty() { + if earlier.first && continuation.is_none() { "This is the first Step. No earlier Step has a result.\n".to_string() } else { "No earlier Step has a result.\n".to_string() @@ -132,7 +156,7 @@ pub fn process_step( } else { String::new() }; - for (name, path) in earlier { + for (name, path) in earlier.results { let _ = writeln!( earlier_text, "Earlier Step result {name}: {}", @@ -148,7 +172,7 @@ pub fn process_step( }); format!( "{preamble}This is Step {step} of the Custom process {process}.\n\ - {issue_text}{earlier_text}{SOURCE_EDITS}{HEADLESS}", + {issue_text}{continuing}{earlier_text}{SOURCE_EDITS}{HEADLESS}", preamble = skill_preamble(skill_name, skill_path), ) } @@ -249,7 +273,7 @@ pub fn continuation(issue: &IssueUrl, base: &str, branch: &str, pr_url: Option<& format!( "/thirdshift-implement {url}\n\ \n\ - You are continuing work on branch {branch}, which already has commits (see git log {base}..HEAD). Build on them; don't start over.\n\ + {continuing}\n\ \n\ The base branch is {base}. Review with the `thirdshift-code-review` skill using {base} as the fixed point.\n\ \n\ @@ -268,6 +292,7 @@ pub fn continuation(issue: &IssueUrl, base: &str, branch: &str, pr_url: Option<& {PR_LIFECYCLE}\n\ {HEADLESS}", url = issue.url, + continuing = continuing_work(branch, base), issue_file_instruction = issue_file_instruction(issue), number = issue.number, address_findings = review.address_findings(), @@ -695,9 +720,12 @@ mod tests { "gather", "to-spec", std::path::Path::new("skills/to-spec/SKILL.md"), - &[], - true, + EarlierSteps { + results: &[], + first: true, + }, Some(&issue), + None, ), ] { for instruction in [ @@ -718,9 +746,12 @@ mod tests { "gather", "to-spec", std::path::Path::new("skills/to-spec/SKILL.md"), - &[], - true, + EarlierSteps { + results: &[], + first: true, + }, Some(&issue), + None, ); assert!( on_issue.contains("Run this Step on issue https://github.com/acme/widgets/issues/7."), @@ -731,8 +762,11 @@ mod tests { "gather", "to-spec", std::path::Path::new("skills/to-spec/SKILL.md"), - &[], - true, + EarlierSteps { + results: &[], + first: true, + }, + None, None, ); assert!(!without.contains(".thirdshift-issue.md"), "{without}"); diff --git a/src/prompts_page.rs b/src/prompts_page.rs index 1c17ab1bb..a6828b659 100644 --- a/src/prompts_page.rs +++ b/src/prompts_page.rs @@ -331,8 +331,11 @@ fn prompts() -> Vec { STEP, SKILL_NAME, std::path::Path::new(SKILL_PATH), - &[], - true, + prompt::EarlierSteps { + results: &[], + first: true, + }, + None, None, ), }, @@ -346,11 +349,14 @@ fn prompts() -> Vec { STEP, SKILL_NAME, std::path::Path::new(SKILL_PATH), - &[( - EARLIER_STEP.to_string(), - std::path::PathBuf::from(EARLIER_RESULT), - )], - false, + prompt::EarlierSteps { + results: &[( + EARLIER_STEP.to_string(), + std::path::PathBuf::from(EARLIER_RESULT), + )], + first: false, + }, + None, None, ), }, @@ -364,9 +370,12 @@ fn prompts() -> Vec { STEP, SKILL_NAME, std::path::Path::new(SKILL_PATH), - &[], - true, + prompt::EarlierSteps { + results: &[], + first: true, + }, Some(&issue), + None, ), }, ]; diff --git a/src/pull_request.rs b/src/pull_request.rs index 162226f78..46e6d5c04 100644 --- a/src/pull_request.rs +++ b/src/pull_request.rs @@ -282,6 +282,27 @@ impl PullRequest { Ok(identified) } + /// Open a draft pull request from this Issue branch when the branch has + /// none. An existing pull request, draft or ready, is left to + /// [`PullRequest::mark_ready`]. + pub fn open_if_absent(&mut self, title: &str, body: &str) -> Result<()> { + if self.observe(false)?.is_some() { + return Ok(()); + } + progress::step(format!("opening a draft pull request into {}", self.base)); + let url = self + .adapter + .create_draft(&self.issue, &self.branch, &self.base, title, body)?; + let url = url.trim().to_string(); + let number = self.number_from_url(&url)?; + self.identified = Some(Identified { number, url }); + let snapshot = self + .observe(false)? + .context("the pull request just opened is not found")?; + self.validate(&snapshot, false)?; + Ok(()) + } + /// Immutable information for Delivery's final accounting, including /// identity captured only by Failed run completion discovery. pub fn info(&self) -> Option { diff --git a/src/recovery.rs b/src/recovery.rs index f66a9df1d..9b760f44d 100644 --- a/src/recovery.rs +++ b/src/recovery.rs @@ -57,6 +57,24 @@ pub enum Preservation { None, } +/// Where a preserve attempt left the work. A push that fails is local, and +/// `report` receives the failed-ticket-run line. +pub fn preservation_of( + result: Result, + report: impl FnOnce(String), +) -> Preservation { + match result { + Ok(crate::worktree::Preserved::Pushed) => Preservation::Remote, + Ok(crate::worktree::Preserved::NoChanges) => Preservation::None, + Err(problem) => { + report(format!( + "could not push the failed ticket run's work, so it may exist only locally: {problem:#}" + )); + Preservation::Local + } + } +} + /// Public command context, filled after Base branch and effective asks settle. #[derive(Debug, PartialEq, Eq)] pub struct Continuation { diff --git a/tests/process.rs b/tests/process.rs index fcaf2032d..4bb2d645d 100644 --- a/tests/process.rs +++ b/tests/process.rs @@ -248,10 +248,10 @@ description = \"Review the diff\" [[steps]] name = \"review\" -catalogue = \"delivery\" +catalogue = \"publish\" ", ), - ":6: exactly one kind: catalogue must be run-published-issue", + ":6: catalogue: catalogue must be delivery or run-published-issue", ), ( "review", @@ -1386,7 +1386,7 @@ version = 1 description = \"Branch\" [worktrees.fix] -kind = \"issue-branch\" +kind = \"orbit\" ", ":5: worktree kind:", ), @@ -2192,7 +2192,8 @@ fn a_missing_issue_value_or_extra_argument_is_a_usage_error() { let cases: &[&[&str]] = &[ &["process", "notes", "issue"], &["process", "notes", "issue", "7", "extra"], - &["process", "check", "notes", "issue", "7"], + &["process", "check", "notes", "issue"], + &["process", "check", "notes", "issue", "7", "extra"], &["process", "issue", "7"], ]; for args in cases { @@ -3693,3 +3694,596 @@ fn an_origin_mismatch_still_sends_the_run_notification() { ); assert_eq!(resend.requests().len(), 1, "{:?}", resend.requests()); } + +const SHIP: &str = "\ +version = 1 +description = \"Ship a fix\" + +[worktrees.fix] +kind = \"issue-branch\" + +[[steps]] +name = \"implement\" +skill = \"code-review\" +worktree = \"fix\" + +[[steps]] +name = \"review\" +skill = \"code-review\" +worktree = \"fix\" + +[[steps]] +name = \"ship\" +catalogue = \"delivery\" +worktree = \"fix\" +"; + +#[test] +fn an_issue_branch_without_a_delivery_after_its_editing_steps_is_refused() { + let body = "\ +version = 1 +description = \"Ship a fix\" + +[worktrees.fix] +kind = \"issue-branch\" + +[[steps]] +name = \"implement\" +skill = \"code-review\" +worktree = \"fix\" +"; + for check in [true, false] { + let scenario = Scenario::new(); + write_process(&scenario, "ship", body); + let result = if check { + scenario.run(&["process", "check", "ship"]) + } else { + scenario.run(&["process", "ship", "issue", "7"]) + }; + assert_no_session(&scenario, &result); + assert!( + result.stderr.contains(":10: delivery:"), + "check={check} expected the delivery rule in {}", + result.stderr + ); + assert_eq!(scenario.issue_labels(7), Vec::::new()); + assert!(scenario.origin_log("issue-7").is_none()); + } +} + +fn refuse_issue_branch(body: &str, needle: &str) { + for check in [true, false] { + let scenario = Scenario::new(); + write_process(&scenario, "ship", body); + let result = if check { + scenario.run(&["process", "check", "ship", "issue", "7"]) + } else { + scenario.run(&["process", "ship", "issue", "7"]) + }; + assert_no_session(&scenario, &result); + assert!( + result.stderr.contains(needle), + "check={check} expected {needle} in {}", + result.stderr + ); + assert_eq!(scenario.issue_labels(7), Vec::::new()); + assert!(scenario.origin_log("issue-7").is_none()); + } +} + +#[test] +fn an_issue_branch_process_needs_an_issue() { + for check in [true, false] { + let scenario = Scenario::new(); + write_process(&scenario, "ship", SHIP); + let result = if check { + scenario.run(&["process", "check", "ship"]) + } else { + scenario.run(&["process", "ship"]) + }; + assert_no_session(&scenario, &result); + assert!( + result.stderr.contains(":5: issue:"), + "check={check} expected the issue rule in {}", + result.stderr + ); + assert!( + result + .stderr + .contains("an Issue-branch worktree needs an issue"), + "check={check} {}", + result.stderr + ); + assert_eq!(scenario.issue_labels(7), Vec::::new()); + assert!(scenario.origin_log("issue-7").is_none()); + } +} + +#[test] +fn check_of_an_issue_branch_process_prints_the_plan_and_starts_no_session() { + let scenario = Scenario::new(); + write_process(&scenario, "ship", SHIP); + scenario.issue_labelled(7, &["ready-for-agent"]); + + let result = scenario.run(&["process", "check", "ship", "issue", "7"]); + + assert_eq!(result.code, Some(0), "{}", result.stderr); + assert!(scenario.claude_calls().is_empty(), "a session started"); + assert!( + result.stdout.contains("ship: Ship a fix"), + "{}", + result.stdout + ); + assert!( + result.stdout.contains("worktree fix: Issue branch"), + "{}", + result.stdout + ); + assert!( + result.stdout.contains("ship: delivery (worktree fix)"), + "{}", + result.stdout + ); + assert!( + result.stdout.contains("implement: thirdshift-code-review"), + "{}", + result.stdout + ); + assert_eq!(scenario.issue_labels(7), ["ready-for-agent"]); + assert!(scenario.origin_log("issue-7").is_none()); + assert_eq!(worktree_count(&scenario), 1); + assert!(!process_dir(&scenario).exists()); + + let throwaway = Scenario::new(); + write_process(&throwaway, "notes", REVIEW); + let plan = throwaway.run(&["process", "check", "notes"]); + assert_eq!(plan.code, Some(0), "{}", plan.stderr); + assert!( + plan.stdout + .contains("throwaway worktree at the Base branch") + ); +} + +#[test] +fn an_issue_branch_process_is_refused_when_its_shape_would_lose_work() { + refuse_issue_branch( + "\ +version = 1 +description = \"Ship a fix\" + +[worktrees.fix] +kind = \"issue-branch\" + +[worktrees.again] +kind = \"issue-branch\" + +[[steps]] +name = \"implement\" +skill = \"code-review\" +worktree = \"fix\" + +[[steps]] +name = \"ship\" +catalogue = \"delivery\" +worktree = \"fix\" +", + ":8: issue-branch:", + ); + refuse_issue_branch( + "\ +version = 1 +description = \"Ship a fix\" + +[worktrees.fix] +kind = \"issue-branch\" + +[[steps]] +name = \"implement\" +skill = \"code-review\" +worktree = \"fix\" + +[[steps]] +name = \"ship\" +catalogue = \"self-merge\" +worktree = \"fix\" +", + ":14: catalogue:", + ); + refuse_issue_branch( + "\ +version = 1 +description = \"Ship a fix\" + +[worktrees.fix] +kind = \"issue-branch\" + +[[steps]] +name = \"ship\" +catalogue = \"delivery\" +optional = true +worktree = \"fix\" +", + ":9: delivery:", + ); + refuse_issue_branch( + "\ +version = 1 +description = \"Ship a fix\" + +[[steps]] +name = \"ship\" +catalogue = \"delivery\" +", + ":6: delivery:", + ); + refuse_issue_branch( + "\ +version = 1 +description = \"Ship a fix\" + +[worktrees.fix] +kind = \"issue-branch\" + +[worktrees.notes] +kind = \"throwaway\" + +[[steps]] +name = \"implement\" +skill = \"code-review\" +worktree = \"fix\" + +[[steps]] +name = \"ship\" +catalogue = \"delivery\" +worktree = \"notes\" +", + ":18: delivery:", + ); + refuse_issue_branch( + "\ +version = 1 +description = \"Ship a fix\" + +[worktrees.fix] +kind = \"issue-branch\" + +[worktrees.notes] +kind = \"throwaway\" + +[[steps]] +name = \"implement\" +skill = \"code-review\" +worktree = \"notes\" +parallel = \"together\" + +[[steps]] +name = \"ship\" +catalogue = \"delivery\" +worktree = \"fix\" +parallel = \"together\" +", + ":20: delivery:", + ); + refuse_issue_branch( + "\ +version = 1 +description = \"Ship a fix\" + +[worktrees.fix] +kind = \"issue-branch\" + +[worktrees.notes] +kind = \"throwaway\" + +[[steps]] +name = \"notes\" +command = \"true\" +worktree = \"notes\" +", + ":5: delivery:", + ); + refuse_issue_branch( + "\ +version = 1 +description = \"Ship a fix\" + +[worktrees.fix] +kind = \"issue-branch\" + +[[steps]] +name = \"implement\" +skill = \"code-review\" +worktree = \"fix\" + +[[steps]] +name = \"ship\" +catalogue = \"delivery\" +worktree = \"fix\" + +[[steps]] +name = \"review\" +skill = \"code-review\" +worktree = \"fix\" +", + ":20: delivery:", + ); +} + +#[test] +fn an_issue_branch_process_ends_ready_for_review_and_removes_the_claim() { + let scenario = Scenario::new(); + write_process(&scenario, "ship", SHIP); + scenario.issue_labelled(7, &["ready-for-agent"]); + scenario.agent_does_for_in_session( + 7, + 1, + r#" +gh issue view 7 --repo acme/widgets --json labels | grep -q in-progress +echo feature > feature.txt +git add feature.txt +git commit -q -m "Add feature" +printf 'implemented' > "$FAKE_CLAUDE_FINAL_MESSAGE" +"#, + ); + scenario.agent_does_for_in_session( + 7, + 2, + r#" +test -f feature.txt +printf 'reviewed' > "$FAKE_CLAUDE_FINAL_MESSAGE" +"#, + ); + + let result = scenario.run(&["process", "ship", "issue", "7"]); + + assert_eq!(result.code, Some(0), "{}", result.stderr); + assert_eq!(result.stdout, "https://github.com/acme/widgets/pull/1\n"); + assert_eq!(scenario.claude_calls().len(), 2, "{}", result.stderr); + let pr = &scenario.gh_state()["prs"][0]; + assert_eq!(pr["state"], "OPEN"); + assert_eq!(pr["isDraft"], false); + assert_eq!(pr["head"], "issue-7"); + assert_eq!(pr["base"], "main"); + assert!( + pr["body"].as_str().unwrap().contains("Closes #7"), + "{}", + pr["body"] + ); + assert_eq!(scenario.issue_labels(7), Vec::::new()); + assert!( + result + .stderr + .contains("labelling #7 in-progress, in place of ready-for-agent"), + "{}", + result.stderr + ); + assert!( + result.stderr.contains("removing in-progress from issue #7"), + "{}", + result.stderr + ); + assert_eq!( + scenario.origin_file("issue-7", "feature.txt").as_deref(), + Some("feature\n") + ); + scenario.assert_cleaned_up("issue-7"); +} + +#[test] +fn a_step_that_fails_before_delivery_keeps_the_issue_branch_work() { + let scenario = Scenario::new(); + write_process(&scenario, "ship", SHIP); + scenario.issue_labelled(7, &["ready-for-agent"]); + scenario.agent_does_for( + 7, + r#" +echo left > left.txt +git add left.txt +exit 1 +"#, + ); + + let result = scenario.run(&["process", "ship", "issue", "7"]); + + assert_eq!(result.code, Some(1), "{}", result.stderr); + assert_eq!(scenario.claude_calls().len(), 1, "{}", result.stderr); + assert!(scenario.gh_state()["prs"].as_array().unwrap().is_empty()); + assert!( + result.stderr.contains("Stopped at: Implementation"), + "{}", + result.stderr + ); + assert!( + result.stderr.contains("Preserved: remotely on issue-7"), + "{}", + result.stderr + ); + assert!( + result.stderr.contains( + "Recovery: thirdshift process ship issue https://github.com/acme/widgets/issues/7" + ), + "{}", + result.stderr + ); + assert!( + result.stderr.contains("Run from the Launch directory"), + "{}", + result.stderr + ); + assert!( + result.stderr.contains("Continuing reuses"), + "{}", + result.stderr + ); + assert_eq!( + scenario.origin_file("issue-7", "left.txt").as_deref(), + Some("left\n") + ); + assert_eq!(scenario.issue_labels(7), ["in-progress"]); + scenario.assert_cleaned_up("issue-7"); +} + +#[test] +fn a_delivery_repair_uses_the_commands_harness_model_and_effort() { + let scenario = Scenario::new(); + write_process( + &scenario, + "ship", + "\ +version = 1 +description = \"Ship a fix\" + +[worktrees.fix] +kind = \"issue-branch\" + +[[steps]] +name = \"implement\" +skill = \"code-review\" +worktree = \"fix\" + +[[steps]] +name = \"ship\" +catalogue = \"delivery\" +worktree = \"fix\" +", + ); + scenario.user_config_is( + "[harness]\ndefault = \"claude\"\n[harness.claude]\nmodel = \"opus\"\neffort = \"low\"\n", + ); + scenario.issue_labelled(7, &["ready-for-agent"]); + let red = r#"[{"name": "test", "conclusion": "failure", "url": "https://ci.example/test"}]"#; + let green = r#"[{"name": "test", "conclusion": "success", "url": "https://ci.example/test"}]"#; + scenario.agent_does_for_in_session( + 7, + 1, + &format!( + "echo feature > feature.txt\n\ + git add feature.txt\n\ + git commit -q -m 'Add feature'\n\ + gh fake checks \"$(git rev-parse HEAD)\" '{red}'\n\ + printf 'implemented' > \"$FAKE_CLAUDE_FINAL_MESSAGE\"\n" + ), + ); + scenario.agent_does_for_in_session( + 7, + 2, + &format!( + "echo fix > fix-1.txt\n\ + git add fix-1.txt\n\ + git commit -q -m 'Fix CI 1'\n\ + gh fake checks \"$(git rev-parse HEAD)\" '{green}'\n\ + printf 'fixed' > \"$FAKE_CLAUDE_FINAL_MESSAGE\"\n" + ), + ); + + let result = scenario.run(&["process", "ship", "issue", "7"]); + + assert_eq!(result.code, Some(0), "{}", result.stderr); + assert_eq!(result.stdout, "https://github.com/acme/widgets/pull/1\n"); + assert!( + result.stderr.contains("CI red; starting Repair"), + "{}", + result.stderr + ); + let calls = scenario.claude_calls(); + assert_eq!(calls.len(), 2, "{}", result.stderr); + assert!( + calls[1]["prompt"] + .as_str() + .unwrap() + .starts_with("CI failed on pull request "), + "{}", + calls[1]["prompt"] + ); + assert_eq!(calls[1]["cwd"], calls[0]["cwd"]); + for call in &calls { + let args: Vec = call["argv"] + .as_array() + .unwrap() + .iter() + .map(|arg| arg.as_str().unwrap().to_string()) + .collect(); + let value = |flag: &str| { + args.iter() + .position(|arg| arg == flag) + .and_then(|at| args.get(at + 1)) + .map(String::as_str) + }; + assert_eq!(value("--model"), Some("opus"), "{args:?}"); + assert_eq!(value("--effort"), Some("low"), "{args:?}"); + } + assert_eq!(scenario.gh_state()["prs"][0]["isDraft"], false); + assert_eq!(scenario.origin_log("issue-7").unwrap()[0], "Fix CI 1"); + assert_eq!(scenario.issue_labels(7), Vec::::new()); +} + +#[test] +fn a_continuation_reuses_the_open_issue_branch() { + let scenario = Scenario::new(); + write_process(&scenario, "ship", SHIP); + scenario.origin_has_branch("issue-7", "main", &["Earlier work"]); + let url = scenario.github_has_pr("issue-7", "main", "OPEN"); + scenario.issue_labelled(7, &["in-progress"]); + scenario.agent_does_for_in_session( + 7, + 1, + r#" +echo more > more.txt +git add more.txt +git commit -q -m "Add more" +printf 'continued' > "$FAKE_CLAUDE_FINAL_MESSAGE" +"#, + ); + scenario.agent_does_for_in_session( + 7, + 2, + "printf 'reviewed' > \"$FAKE_CLAUDE_FINAL_MESSAGE\"\n", + ); + + let result = scenario.run(&["process", "ship", "issue", "7"]); + + assert_eq!(result.code, Some(0), "{}", result.stderr); + assert_eq!(result.stdout, format!("{url}\n")); + assert_eq!(scenario.gh_state()["prs"].as_array().unwrap().len(), 1); + assert_eq!(scenario.gh_state()["prs"][0]["head"], "issue-7"); + assert_eq!(scenario.gh_state()["prs"][0]["isDraft"], false); + assert!(scenario.origin_log("issue-7-branch-2").is_none()); + assert_eq!(scenario.origin_log("issue-7").unwrap()[0], "Add more"); + assert_eq!(scenario.issue_labels(7), Vec::::new()); + scenario.assert_cleaned_up("issue-7"); +} + +#[test] +fn a_continuation_prompt_differs_from_a_fresh_start() { + let scenario = Scenario::new(); + write_process(&scenario, "ship", SHIP); + scenario.origin_has_branch("issue-7", "main", &["Earlier work"]); + let _url = scenario.github_has_pr("issue-7", "main", "OPEN"); + scenario.issue_labelled(7, &["in-progress"]); + scenario.agent_does_for_in_session( + 7, + 1, + "printf 'continued' > \"$FAKE_CLAUDE_FINAL_MESSAGE\"\n", + ); + scenario.agent_does_for_in_session( + 7, + 2, + "printf 'reviewed' > \"$FAKE_CLAUDE_FINAL_MESSAGE\"\n", + ); + + let result = scenario.run(&["process", "ship", "issue", "7"]); + + assert_eq!(result.code, Some(0), "{}", result.stderr); + let calls = scenario.claude_calls(); + let prompt = calls[0]["prompt"].as_str().unwrap(); + assert!( + prompt.contains("already has commits") + || prompt.contains("Build on them") + || prompt.contains("don't start over"), + "continuation prompt should differ from a fresh start: {prompt}" + ); + assert!( + !prompt.contains("This is the first Step. No earlier Step has a result."), + "continuation prompt still denies earlier work: {prompt}" + ); +}