From c9e3f6fb6efdcfe493f450406e215c9fce3871e5 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Mon, 26 Oct 2026 20:53:03 -0400 Subject: [PATCH 1/2] Skip a Custom process with no issue while another Pass is running (#1386) A Custom process started with no issue takes the repository's Pass lock and is skipped, exiting 0 with no Run notification, while another Pass is still running. Its Command record ends skipped. A process started with an issue does not take the lock and is not skipped by it. --- GLOSSARY.md | 4 +- README.md | 2 +- docs/guide.md | 8 +- site/architecture/index.html | 4 +- site/docs/index.html | 7 +- site/how-it-works/index.html | 2 +- site/index.html | 4 +- src/launch.rs | 17 +++ src/logs.rs | 22 ++- src/logs/recording.rs | 46 +++++- src/processes.rs | 27 +++- tests/process.rs | 287 ++++++++++++++++++++++++++++++++++- 12 files changed, 407 insertions(+), 23 deletions(-) diff --git a/GLOSSARY.md b/GLOSSARY.md index 103e73ab..f5f2e10c 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** or a **Security run**: a **Command** started with no **Issue URL**, which decides before any work whether it is skipped. +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. _Avoid_: tick, cycle ### Architecture @@ -259,7 +259,7 @@ _Avoid_: cron log, output log, run log (a **Ticket run** is one kind of command) The retained Command logs and Session traces available to list on a machine, each naming an exact file under the User config's log folder. A Command's referenced Session traces may include files outside that inventory. **Activity log**: -One running record per repository of what the factory did there: a line when a **Ticket run**, **Spec run**, **Architect run**, **Pickup run** or **Security run** starts work, naming its **Command log**, a line when it is given a **Stop** or a **Dismissal**, or a Dismissal is taken back, naming who asked and whether from the CLI or the **Console**, and a line when it ends, with its outcome. A skipped Architect run, Pickup run or Security run writes a line only when its reason differs from the last one recorded for its kind, so a repository that sits idle shows one line, not one per pass. It is what thirdshift writes about a scheduled pass, in place of output to the scheduler. +One running record per repository of what the factory did there: a line when a **Ticket run**, **Spec run**, **Architect run**, **Pickup run** or **Security run** starts work, naming its **Command log**, a line when it is given a **Stop** or a **Dismissal**, or a Dismissal is taken back, naming who asked and whether from the CLI or the **Console**, and a line when it ends, with its outcome. A skipped Architect run, Pickup run or Security run writes a line only when its reason differs from the last one recorded for its kind, so a repository that sits idle shows one line, not one per pass. A skipped Custom process started with no issue writes `Process skipped: ` when that is not already the last Process line. It is what thirdshift writes about a scheduled pass, in place of output to the scheduler. _Avoid_: cron log, pass log, history **Command record**: diff --git a/README.md b/README.md index fb96b528..4ee5bf3c 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 or command Steps from `~/.thirdshift/processes/.toml`, in named throwaway worktrees when the file declares them. An optional Step can fail without stopping the process. `issue ` gives every session Step that issue. | +| A Custom process | `thirdshift process [issue ]` | An experimental [Custom process](docs/guide.md#custom-processes) runs skill or command Steps from `~/.thirdshift/processes/.toml`, in named throwaway worktrees when the file declares them. 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. | 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/guide.md b/docs/guide.md index 6c588132..ff34457a 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**: they take no Issue URL, so your operating system's scheduler can start them. 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: 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. 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). @@ -1003,6 +1003,8 @@ command that exits non-zero is failed, and the line names its exit status. Stop and Ctrl-C interrupt a command Step the way they interrupt a session, and `[resources]` is checked before each Step. +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. + ### User config A **User config** at `~/.thirdshift/config.toml` sets this machine's defaults for every Ticket run. It is optional: with no file, or one that says nothing about a setting, a Ticket run does only what its command asks for. @@ -1411,7 +1413,7 @@ When a Command that produces a [Run notification](#run-notifications) ends, its A Resume is logged as its session's kind plus `-resume`, e.g. `implement-resume.logs.jsonl`. -A **Command log** is everything one command printed, stderr and stdout in the order printed, while the terminal still shows all of it. Its folder is the command typed: `thirdshift `, a Ticket run or a Spec run, in `issue/`, `thirdshift pickup` in `pickup/`, named for the issue it took, and `thirdshift architect` in `architect/`. A Spec run's covers its Tickets' runs, a Ticket run's covers its [Base fix](#base-fix), and a Pass's covers the Spec run or Ticket run it dispatched: none of those keeps one of its own. So `ls -t ~/.thirdshift/logs/acme/widgets/commands/pickup/*.log | head` lists the recent passes on acme/widgets that took an issue. A command keeps its Command log once it starts work: a Pass skipped before doing any work keeps none, nor does a Ticket run that fails before it starts work, as on the [Origin match](#what-a-ticket-run-does), and `setup`, `email-test`, `update`, `version` and `help` never keep one. Lines printed before the Command log's name is known, such as a Pickup run's lines on the issues it [passed over](#why-an-issue-was-passed-over) before it took one, are written first, and once it is created a progress line says `logging this command to `. A Command log that can't be written produces one `warning:` line on stderr. A failure confined to the Command log leaves the outcome, stdout and exit code unchanged; if the shared folder also prevents writing a Session trace, the Ticket run fails before starting that session. +A **Command log** is everything one command printed, stderr and stdout in the order printed, while the terminal still shows all of it. Its folder is the command typed: `thirdshift `, a Ticket run or a Spec run, in `issue/`, `thirdshift pickup` in `pickup/`, named for the issue it took, and `thirdshift architect` in `architect/`. A Spec run's covers its Tickets' runs, a Ticket run's covers its [Base fix](#base-fix), and a Pass's covers the Spec run or Ticket run it dispatched: none of those keeps one of its own. So `ls -t ~/.thirdshift/logs/acme/widgets/commands/pickup/*.log | head` lists the recent passes on acme/widgets that took an issue. A command keeps its Command log once it starts work: a Pickup run, Architect run or Security run skipped before doing any work keeps none, nor does a Ticket run that fails before it starts work, as on the [Origin match](#what-a-ticket-run-does). A Custom process started with no issue that is skipped keeps a Command record whose outcome is `skipped`, beside the Command log of the lines it held. `setup`, `email-test`, `update`, `version` and `help` never keep one. Lines printed before the Command log's name is known, such as a Pickup run's lines on the issues it [passed over](#why-an-issue-was-passed-over) before it took one, are written first, and once it is created a progress line says `logging this command to `. A Command log that can't be written produces one `warning:` line on stderr. A failure confined to the Command log leaves the outcome, stdout and exit code unchanged; if the shared folder also prevents writing a Session trace, the Ticket run fails before starting that session. The stamp is the local time the command started, with its UTC offset, as in `20261003T120000-0400`, in the machine's time zone, or `TZ`'s if set, so it agrees with `date` and `ls -l`. A command's Command log and all of its Session traces, its Tickets' runs' and its Base fix's included, share that one stamp, so they sort together and each can be found from the other. When a Ticket run fails, stderr ends with the path of the Session trace of the session its cause came from, the place to start looking, then that of its Command log. A failed implement session, Spec review or Repair names its own log, as does a [Security review](#security-review) whose hold is reported only after later Repairs. A cause that came from no session, such as red CI with the Repairs used up, a Declined CI fix, the upstream-move budget, a Harness check or a git or GitHub error, names the most recent Session trace. A Spec run whose Tickets are not done names the Session trace of the one Ticket whose Ticket run failed, and none if several did, as no single session caused it; each failed Ticket's line gives its own. The Command log's closing `session trace:` line and the [Run notification](#run-notifications) give the same path. @@ -1653,7 +1655,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. +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`. 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 78409064..5b96b267 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 Pass 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 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. 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 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.

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 d3e4a8fe..db9696d0 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: they take no Issue URL, so your operating system's scheduler can start them. 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: 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.

    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

    @@ -911,6 +911,7 @@ command that exits non-zero is failed, and the line names its exit status. Stop and Ctrl-C interrupt a command Step the way they interrupt a session, and [resources] is checked before each Step.

    +

    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.

    User config

    @@ -1243,7 +1244,7 @@

    Session traces share the owning command's folder, stamp and owner token: a Pickup run's dispatched Ticket run logs in commands/pickup/, an Architect run's in commands/architect/, and a Security run's in commands/secure/. Tickets and nested Base fixes inherit that same ownership too.

    When a Command that produces a Run notification ends, its subject and body are saved beside its Command log as <Command log stem>.notification.txt, whether or not email is enabled. For example, 7-20261003T120000-0400~aB3cD4eF5gH6iJ7k.log has 7-20261003T120000-0400~aB3cD4eF5gH6iJ7k.notification.txt beside it. The file starts with Subject: <subject>, then a blank line and the exact plain-text email body. A Pickup run writes one for the issue it took, and a Spec run writes one covering its Tickets; dispatched Ticket runs, Ticket runs inside a Spec run and Base fixes write none of their own. A skipped Pass writes none. A failed write produces a warning: line and leaves the outcome unchanged. These files are not included in thirdshift logs or the Console's log inventory.

    A Resume is logged as its session's kind plus -resume, e.g. implement-resume.logs.jsonl.

    -

    A Command log is everything one command printed, stderr and stdout in the order printed, while the terminal still shows all of it. Its folder is the command typed: thirdshift <Issue URL>, a Ticket run or a Spec run, in issue/, thirdshift pickup in pickup/, named for the issue it took, and thirdshift architect in architect/. A Spec run's covers its Tickets' runs, a Ticket run's covers its Base fix, and a Pass's covers the Spec run or Ticket run it dispatched: none of those keeps one of its own. So ls -t ~/.thirdshift/logs/acme/widgets/commands/pickup/*.log | head lists the recent passes on acme/widgets that took an issue. A command keeps its Command log once it starts work: a Pass skipped before doing any work keeps none, nor does a Ticket run that fails before it starts work, as on the Origin match, and setup, email-test, update, version and help never keep one. Lines printed before the Command log's name is known, such as a Pickup run's lines on the issues it passed over before it took one, are written first, and once it is created a progress line says logging this command to <path>. A Command log that can't be written produces one warning: line on stderr. A failure confined to the Command log leaves the outcome, stdout and exit code unchanged; if the shared folder also prevents writing a Session trace, the Ticket run fails before starting that session.

    +

    A Command log is everything one command printed, stderr and stdout in the order printed, while the terminal still shows all of it. Its folder is the command typed: thirdshift <Issue URL>, a Ticket run or a Spec run, in issue/, thirdshift pickup in pickup/, named for the issue it took, and thirdshift architect in architect/. A Spec run's covers its Tickets' runs, a Ticket run's covers its Base fix, and a Pass's covers the Spec run or Ticket run it dispatched: none of those keeps one of its own. So ls -t ~/.thirdshift/logs/acme/widgets/commands/pickup/*.log | head lists the recent passes on acme/widgets that took an issue. A command keeps its Command log once it starts work: a Pickup run, Architect run or Security run skipped before doing any work keeps none, nor does a Ticket run that fails before it starts work, as on the Origin match. A Custom process started with no issue that is skipped keeps a Command record whose outcome is skipped, beside the Command log of the lines it held. setup, email-test, update, version and help never keep one. Lines printed before the Command log's name is known, such as a Pickup run's lines on the issues it passed over before it took one, are written first, and once it is created a progress line says logging this command to <path>. A Command log that can't be written produces one warning: line on stderr. A failure confined to the Command log leaves the outcome, stdout and exit code unchanged; if the shared folder also prevents writing a Session trace, the Ticket run fails before starting that session.

    The stamp is the local time the command started, with its UTC offset, as in 20261003T120000-0400, in the machine's time zone, or TZ's if set, so it agrees with date and ls -l. A command's Command log and all of its Session traces, its Tickets' runs' and its Base fix's included, share that one stamp, so they sort together and each can be found from the other. When a Ticket run fails, stderr ends with the path of the Session trace of the session its cause came from, the place to start looking, then that of its Command log. A failed implement session, Spec review or Repair names its own log, as does a Security review whose hold is reported only after later Repairs. A cause that came from no session, such as red CI with the Repairs used up, a Declined CI fix, the upstream-move budget, a Harness check or a git or GitHub error, names the most recent Session trace. A Spec run whose Tickets are not done names the Session trace of the one Ticket whose Ticket run failed, and none if several did, as no single session caused it; each failed Ticket's line gives its own. The Command log's closing session trace: line and the Run notification give the same path.

    Command logs and Session traces insert ~<token> immediately after the stamp. The random token gives each Command its own storage even when start times agree; its Command record uses the exact Command-log stem followed by .command.json. Model sidecars (.models.json), review reports (.standards and .spec) and review summaries (.review-summary.json) use the Session stem before .logs.jsonl, for example 7-<stamp>~<token>-implement.models.json. The Command record names the log-records file; Silent watches that file grow, and the Waterfall reads the trace spans. Human Command names stay the same, and start stamps remain display and grouping facts. A matching stamp alone does not attach a tokenless Session to a new Command: a record reference or exact Model evidence must establish that ownership. Converted traces and older Command records remain readable without renaming; a record naming the former raw file resolves to its log-records file. Readers no longer accept the old Session-log format, and the one-time logs --convert and --remove-converted commands have been retired. A failed Command-log writer retains its allocated owner; if allocation itself failed, a Session fails with that cause instead of choosing a shared timestamp-only filename.

    The code review writes summary.json beside its reports with finding counts by Standards and Spec, including findings later fixed or declined. At Ticket run completion, the review's originating Session stage keeps these in its Run metrics as review_findings, with standards and spec counts. Missing or invalid summaries are null, and an unavailable axis is null rather than zero. A Resume updates that same review's summary; another review keeps its own counts. show --json reads these retained counts without parsing report prose. A release changing this Factory skill needs an Experiment once #977 exists.

    @@ -1433,7 +1434,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.

    +

    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.

    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 b0a026d9..ee0c8f3a 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 or a command. 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. Custom processes.

    +

    thirdshift process is experimental, beside thirdshift skill. It runs the Steps in ~/.thirdshift/processes/<name>.toml, a skill session or a command. 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. Custom processes.

    Different entry points, the same implementation and Delivery
    diff --git a/site/index.html b/site/index.html index 2fc7386a..e8e75b2b 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 or command Steps from ~/.thirdshift/processes, including Steps that run together in throwaway worktrees the file names. 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. Custom processes.

    +

    thirdshift skill runs one skill. thirdshift process is experimental: it runs skill or command Steps from ~/.thirdshift/processes, including Steps that run together in throwaway worktrees the file names. 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. 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. Your operating system schedules them. thirdshift status, thirdshift cronjobs and the Console show when each repository's 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 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.

    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/launch.rs b/src/launch.rs index d273804b..44a915a6 100644 --- a/src/launch.rs +++ b/src/launch.rs @@ -421,6 +421,23 @@ pub fn resolve_issue(input: IssueInput) -> Result { }) } +/// Take the lock that makes this process the one Pass on `repo`, and hold it +/// until this process exits. `Some` when another Pass already holds it: this +/// call took nothing. A Custom process started with no issue uses this; one +/// started with an issue does not, and `start` still does the preflight a +/// Pickup run, Architect run or Security run does before its own lock. +pub fn hold_pass(repo: &Repo) -> Result> { + match try_pass_lock(repo)? { + Some(lock) => { + // Never closed, so the lock is held for as long as this process + // lives. The operating system releases it however the process ends. + std::mem::forget(lock); + Ok(None) + } + None => Ok(Some(AlreadyRunning(repo.clone()))), + } +} + /// Try, without waiting, for the lock that makes its holder the one Pass on /// `repo` on this machine: an advisory lock on a file under `~/.thirdshift` /// named for the repository, as GitHub compares names, whatever their case. It is held until the file returned is closed, or the diff --git a/src/logs.rs b/src/logs.rs index 0c00eb60..10a17a3d 100644 --- a/src/logs.rs +++ b/src/logs.rs @@ -130,7 +130,12 @@ pub enum Begin<'a> { ScoringRun, Experiment, /// `thirdshift process [issue ]`, a Custom process. - Process(&'a str), + /// `pass` holds the first lines, as the other Passes do, when it was + /// started with no issue. + Process { + name: &'a str, + pass: bool, + }, } /// The work a command starts. @@ -262,6 +267,21 @@ pub fn skipped(pass: Pass, repo: &Repo, reason: impl Display) { record().skipped(pass, repo, reason); } +/// Record that the Custom process `name`, started with no issue, was skipped +/// on `repo` for `reason`. The Activity log keeps `Process skipped: +/// ` unless that is already its last Process line. Unlike the other +/// Passes, it also keeps a Command record whose outcome is `skipped`: the +/// Process's record shows that ending. `harness` is the choice it would have +/// run on; the skip does not check that it can run. +pub fn skipped_process( + repo: &Repo, + name: &str, + harness: &crate::harness::Choice, + reason: impl Display, +) { + record().skipped_process(repo, name, harness, &reason.to_string()); +} + /// Add an observation to the running command's repository Activity log. pub(crate) fn activity(message: impl Display) { record().activity(message); diff --git a/src/logs/recording.rs b/src/logs/recording.rs index 4c42f1f3..92f97ec2 100644 --- a/src/logs/recording.rs +++ b/src/logs/recording.rs @@ -106,7 +106,7 @@ impl Record { Begin::SecurityRun => CommandKind::Secure, Begin::ScoringRun => CommandKind::Scoring, Begin::Experiment => CommandKind::Experiment, - Begin::Process(_) => CommandKind::Process, + Begin::Process { .. } => CommandKind::Process, }; let (starting, quiet) = match begin { Begin::ChildRun(stamp, _) => { @@ -120,7 +120,7 @@ impl Record { Begin::SecurityRun => ("Security run starting".into(), true), Begin::ScoringRun => ("Scoring run starting".into(), false), Begin::Experiment => ("Experiment starting".into(), false), - Begin::Process(name) => (format!("Process {name} starting"), false), + Begin::Process { name, pass } => (format!("Process {name} starting"), pass), }; let now = self.outside.now(); self.stamp @@ -306,6 +306,30 @@ impl Record { ); } + /// A skipped Custom process keeps the Activity log line and a Command + /// record. The record is what shows the skipped ending; the other Passes + /// drop the Command log they never kept. + pub(super) fn skipped_process( + &mut self, + repo: &Repo, + name: &str, + harness: &Choice, + reason: &str, + ) { + let root = self.root(repo); + self.activity_write( + &root, + &format!("Process {name} skipped: {reason}"), + Some(Kind::Process), + ); + let stamp = self.stamp(); + // Leave held lines unshown, so a quiet skip prints nothing. They are + // still the Command log. + self.retain(&root, Work::Process { repo, name }, &stamp, false); + self.start_record(Work::Process { repo, name }, harness); + self.outcome("skipped", Some(reason)); + } + pub(super) fn activity(&mut self, message: impl Display) { if let Some(started) = &self.activity_started { let root = started.root.clone(); @@ -600,7 +624,17 @@ impl Record { } fn keep(&mut self, root: &Path, work: Work<'_>, stamp: &str) { - self.show_held(); + self.retain(root, work, stamp, true); + } + + /// Keep the Command log. `announce` shows lines held from the terminal and + /// names the log there. A skipped Custom process passes `false`: under + /// `activity.quiet_skips` those lines stay unprinted, and the log-path + /// line stays off the terminal either way. + fn retain(&mut self, root: &Path, work: Work<'_>, stamp: &str, announce: bool) { + if announce { + self.show_held(); + } if !matches!(self.command_log, Log::Held { .. }) { return; } @@ -630,8 +664,10 @@ impl Record { .to_path_buf(), writer, }; - let path = self.command_log_path().unwrap(); - self.step(format_args!("logging this command to {}", path.display())); + if announce { + let path = self.command_log_path().unwrap(); + self.step(format_args!("logging this command to {}", path.display())); + } } Err(error) => { self.owner_error = Some(format!("{error:#}")); diff --git a/src/processes.rs b/src/processes.rs index 23740082..461b5d40 100644 --- a/src/processes.rs +++ b/src/processes.rs @@ -2,6 +2,11 @@ //! //! 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. +//! +//! 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. use std::collections::BTreeSet; use std::fmt::Write; @@ -173,6 +178,22 @@ fn run(name: &str, issue: Option) -> Result { // Before any worktree: a foreign Issue URL is an origin mismatch, and a // number that cannot be resolved stops the same way. let issue = resolve_process_issue(issue)?; + // 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. + if issue.is_none() { + logs::begin(Begin::Process { name, pass: true }); + logs::configured(&config); + let _base = launch::checked_out_base()?; + let repo = launch::repo()?; + if let Some(running) = launch::hold_pass(&repo)? { + let reason = running.to_string(); + let choice = Choice::of(&Asked::default(), &config.harness); + progress::step(&reason); + logs::skipped_process(&repo, name, &choice, &reason); + return Ok(String::new()); + } + } // The same margin as a Ticket run. Edits in the gap since this moment, // or dated by a slow GitHub clock, still count. let started = Utc::now() - crate::run::STARTED_MARGIN; @@ -180,8 +201,10 @@ fn run(name: &str, issue: Option) -> Result { prepared.issue = issue; prepared.started = started; let repo = launch::repo()?; - logs::begin(Begin::Process(name)); - logs::configured(&config); + if prepared.issue.is_some() { + logs::begin(Begin::Process { name, pass: false }); + logs::configured(&config); + } logs::started(Work::Process { repo: &repo, name }, &prepared.choice); let result = execute_steps(name, &prepared); let (outcome, cause) = classify(&result); diff --git a/tests/process.rs b/tests/process.rs index c5f59ba6..9da1e2ea 100644 --- a/tests/process.rs +++ b/tests/process.rs @@ -6,7 +6,8 @@ use std::fs; use std::os::unix::fs::symlink; use std::path::Path; -use support::{RunResult, Scenario, leaves_running}; +use support::resend::ResendStandIn; +use support::{RunResult, Scenario, after_start, leaves_running}; const REVIEW: &str = "\ version = 1 @@ -2170,6 +2171,290 @@ fn a_missing_issue_value_or_extra_argument_is_a_usage_error() { } } +/// What a skipped Pass says when another Pass is running on the scenario's +/// repository. The same words a skipped Pickup run uses. +const ALREADY_RUNNING: &str = "thirdshift: another Pass is already running on acme/widgets\n"; + +/// The reason kept on the Command record and in the Activity log, without +/// the progress prefix. +const ALREADY_RUNNING_REASON: &str = "another Pass is already running on acme/widgets"; + +/// A script in which the agent touches `started` in the scenario root, then +/// waits there until the test touches `release`. +const AGENT_WAITS_FOR_RELEASE: &str = r#" +root=$(dirname "$FAKE_CLAUDE_RECORD") +touch "$root/started" +while [ -d "$root" ] && [ ! -e "$root/release" ]; do sleep 0.05; done +"#; + +/// The User config of a machine where every Run and every Pickup run that +/// takes an issue sends a Run notification. +const EMAIL_ALWAYS: &str = "[email]\nalways = true\nto = \"config@example.com\"\n"; + +fn release(scenario: &Scenario) { + fs::write(scenario.path("release"), "").unwrap(); +} + +fn ready_issue(scenario: &Scenario, number: u32) { + scenario.issue_is(number, "OPEN"); + scenario.issue_labelled(number, &["ready-for-agent"]); +} + +/// A script in which the agent for issue `issue` commits its work and opens +/// its PR into `main`, leaving the pushing to thirdshift. +fn agent_opens_pr(issue: u32) -> String { + format!( + r#" +echo "{issue}" > issue-{issue}.txt +git add issue-{issue}.txt +git commit -q -m "Work on {issue}" +gh pr create --base main --head issue-{issue} --title "Work on {issue}" --body "Closes #{issue}" +"# + ) +} + +fn command_process(scenario: &Scenario, name: &str, command: &str) { + write_process( + scenario, + name, + &format!( + "\ +version = 1 +description = \"Run a command\" + +[[steps]] +name = \"act\" +command = \"{command}\" +" + ), + ); +} + +/// A command Step that touches `started`, waits for `release`, then prints. +fn waiting_process(scenario: &Scenario, name: &str) { + let started = scenario.path("started"); + let release = scenario.path("release"); + command_process( + scenario, + name, + &format!( + "touch '{}' && while [ ! -e '{}' ]; do sleep 0.05; done && printf held", + started.display(), + release.display() + ), + ); +} + +fn activity(scenario: &Scenario) -> String { + fs::read_to_string(scenario.path("home/.thirdshift/logs/acme/widgets/activity.log")) + .unwrap_or_default() +} + +#[test] +fn a_process_with_no_issue_is_skipped_while_a_pickup_run_is_still_running() { + let scenario = Scenario::new(); + let marker = scenario.path("step-ran"); + command_process( + &scenario, + "review", + &format!("touch '{}' && printf ran", marker.display()), + ); + ready_issue(&scenario, 7); + scenario.agent_does(AGENT_WAITS_FOR_RELEASE); + let pickup = scenario.run_until(&["pickup"], &[], "started"); + let work = scenario.entries("work"); + let calls = scenario.claude_calls().len(); + scenario.user_config_is(EMAIL_ALWAYS); + let resend = ResendStandIn::replying(200, r#"{"id":"49a3999c-0ce1-4ea6-ab68-afcd6dc2e794"}"#); + + let result = scenario.run_with_env( + &["process", "review"], + &[ + ("THIRDSHIFT_RESEND_URL", resend.url()), + ("RESEND_API_KEY", "re_test_123"), + ], + ); + + assert_eq!(result.code, Some(0), "{}", result.stderr); + assert_eq!(result.stdout, ""); + assert_eq!( + after_start(&result.stderr, "Process review starting"), + ALREADY_RUNNING + ); + assert!(!marker.exists(), "the step ran"); + assert_eq!(scenario.entries("work"), work); + assert_eq!(scenario.claude_calls().len(), calls, "a session started"); + assert!(resend.requests().is_empty(), "{:?}", resend.requests()); + let record = process_record(&scenario); + assert_eq!(record["kind"], "Process"); + assert_eq!(record["outcome"], "skipped"); + assert_eq!(record["cause"], ALREADY_RUNNING_REASON); + assert!( + record["name"] + .as_str() + .unwrap() + .starts_with("process-review-"), + "{}", + record["name"] + ); + let (_, log) = command_log(&scenario); + assert!(log.contains(ALREADY_RUNNING_REASON), "{log}"); + assert!( + activity(&scenario).contains(&format!("Process review skipped: {ALREADY_RUNNING_REASON}")), + "{}", + activity(&scenario) + ); + release(&scenario); + pickup.finish(); +} + +#[test] +fn a_pickup_run_is_skipped_while_a_process_with_no_issue_is_still_running() { + let scenario = Scenario::new(); + waiting_process(&scenario, "review"); + ready_issue(&scenario, 7); + scenario.agent_does(&agent_opens_pr(7)); + let process = scenario.run_until(&["process", "review"], &[], "started"); + let (github, gh_calls) = (scenario.gh_state(), scenario.gh_calls()); + + let skipped = scenario.run(&["pickup"]); + + assert_eq!(skipped.code, Some(0), "{}", skipped.stderr); + assert_eq!(skipped.stdout, ""); + assert_eq!( + after_start(&skipped.stderr, "Pickup run starting"), + ALREADY_RUNNING + ); + assert!(scenario.claude_calls().is_empty(), "a session started"); + assert_eq!(scenario.gh_state(), github); + assert_eq!(scenario.gh_calls(), gh_calls); + assert_eq!(scenario.issue_labels(7), ["ready-for-agent"]); + release(&scenario); + let process = process.finish(); + assert_eq!(process.code, Some(0), "{}", process.stderr); + assert_eq!(process.stdout, "held\n"); +} + +#[test] +fn a_process_started_with_an_issue_runs_while_a_pickup_run_holds_the_repository() { + let scenario = Scenario::new(); + command_process(&scenario, "notes", "printf ran"); + ready_issue(&scenario, 7); + scenario.agent_does(AGENT_WAITS_FOR_RELEASE); + let pickup = scenario.run_until(&["pickup"], &[], "started"); + + let result = scenario.run(&["process", "notes", "issue", "7"]); + + assert_eq!(result.code, Some(0), "{}", result.stderr); + assert_eq!(result.stdout, "ran\n"); + assert!( + !result.stderr.contains("another Pass is already running"), + "{}", + result.stderr + ); + assert_eq!(process_record(&scenario)["outcome"], "succeeded"); + release(&scenario); + pickup.finish(); +} + +#[test] +fn a_pickup_run_takes_a_ready_issue_while_a_process_with_an_issue_is_running() { + let scenario = Scenario::new(); + waiting_process(&scenario, "notes"); + ready_issue(&scenario, 9); + scenario.agent_does_for(9, &agent_opens_pr(9)); + let process = scenario.run_until(&["process", "notes", "issue", "7"], &[], "started"); + + let pickup = scenario.run(&["pickup"]); + + assert_eq!(pickup.code, Some(0), "{}", pickup.stderr); + assert!( + pickup + .stdout + .contains("https://github.com/acme/widgets/pull/"), + "{}", + pickup.stdout + ); + assert!( + !pickup.stderr.contains("another Pass is already running"), + "{}", + pickup.stderr + ); + assert_eq!( + scenario.claude_calls().len(), + 1, + "pickup started no session" + ); + assert_ne!(scenario.issue_labels(9), ["ready-for-agent"]); + release(&scenario); + let process = process.finish(); + assert_eq!(process.code, Some(0), "{}", process.stderr); + assert_eq!(process.stdout, "held\n"); +} + +#[test] +fn a_process_with_no_issue_does_not_stop_for_a_ready_issue_or_an_architect_idea() { + let scenario = Scenario::new(); + command_process(&scenario, "review", "printf ran"); + ready_issue(&scenario, 7); + scenario.issue_is(8, "OPEN"); + scenario.issue_labelled(8, &["architect-idea", "needs-triage"]); + + let result = scenario.run(&["process", "review"]); + + assert_eq!(result.code, Some(0), "{}", result.stderr); + assert_eq!(result.stdout, "ran\n"); + assert!( + !result.stderr.contains("another Pass is already running"), + "{}", + result.stderr + ); + assert!(!result.stderr.contains("goes first"), "{}", result.stderr); + assert!( + !result.stderr.contains("Architect idea"), + "{}", + result.stderr + ); + assert!(scenario.claude_calls().is_empty(), "a session started"); + assert_eq!(process_record(&scenario)["outcome"], "succeeded"); + assert_eq!(scenario.issue_labels(7), ["ready-for-agent"]); + assert_eq!(scenario.issue_labels(8), ["architect-idea", "needs-triage"]); +} + +#[test] +fn a_quiet_skip_of_a_process_with_no_issue_prints_nothing_and_keeps_the_record() { + let scenario = Scenario::new(); + scenario.user_config_is("[activity]\nquiet_skips = true\n"); + let marker = scenario.path("step-ran"); + command_process( + &scenario, + "review", + &format!("touch '{}' && printf ran", marker.display()), + ); + ready_issue(&scenario, 7); + scenario.agent_does(AGENT_WAITS_FOR_RELEASE); + let pickup = scenario.run_until(&["pickup"], &[], "started"); + + let result = scenario.run(&["process", "review"]); + + assert_eq!(result.code, Some(0), "{}", result.stderr); + assert_eq!(result.stdout, ""); + assert_eq!(result.stderr, ""); + assert!(!marker.exists(), "the step ran"); + let record = process_record(&scenario); + assert_eq!(record["outcome"], "skipped"); + assert_eq!(record["cause"], ALREADY_RUNNING_REASON); + let (_, log) = command_log(&scenario); + assert!(log.contains(ALREADY_RUNNING_REASON), "{log}"); + assert!( + activity(&scenario).contains(&format!("Process review skipped: {ALREADY_RUNNING_REASON}")), + "{}", + activity(&scenario) + ); + release(&scenario); + pickup.finish(); +} + fn family_sessions(members: &str, judge: &str) -> String { format!( r#"name=$(printf '%s\n' "$FAKE_CLAUDE_PROMPT" | sed -n 's/^This is Step \([^ ]*\) of the Custom process .*/\1/p') From e7a6f780ead1f2547edfe10ca7f185680bd98d7f Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Mon, 26 Oct 2026 21:06:01 -0400 Subject: [PATCH 2/2] Hold the Pass lock in one place Pickup, Architect and Security runs take the same lock a Custom process with no issue holds, after their own preflight. --- src/launch.rs | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/launch.rs b/src/launch.rs index 44a915a6..f8efa308 100644 --- a/src/launch.rs +++ b/src/launch.rs @@ -367,13 +367,9 @@ pub fn start(base: Option<&str>) -> Result { crate::logs::pass_fired(&repo); directory.check_identity()?; let base = directory.prepare_base_branch(base)?; - let Some(lock) = try_pass_lock(&repo)? else { - return Ok(Start::AlreadyRunning(AlreadyRunning(repo))); - }; - // Never closed, so the lock is held for as long as this process lives, - // through the Spec run or Run it dispatches, and the operating system - // releases it however the process ends. - std::mem::forget(lock); + if let Some(running) = hold_pass(&repo)? { + return Ok(Start::AlreadyRunning(running)); + } Ok(Start::Clear(Launch { directory, repo, @@ -424,8 +420,8 @@ pub fn resolve_issue(input: IssueInput) -> Result { /// Take the lock that makes this process the one Pass on `repo`, and hold it /// until this process exits. `Some` when another Pass already holds it: this /// call took nothing. A Custom process started with no issue uses this; one -/// started with an issue does not, and `start` still does the preflight a -/// Pickup run, Architect run or Security run does before its own lock. +/// started with an issue does not. `start` calls this after the preflight a +/// Pickup run, Architect run or Security run does. pub fn hold_pass(repo: &Repo) -> Result> { match try_pass_lock(repo)? { Some(lock) => {