diff --git a/GLOSSARY.md b/GLOSSARY.md index f4993167b..37c4b81be 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -27,7 +27,7 @@ An issue scoping one session of work, standing alone or as a sub-issue of a **Sp _Avoid_: task, sub-task **Ready issue**: -An open issue a **Pickup run** may take: labelled `ready-for-agent`, written by a **Trusted account**, with its most recent `ready-for-agent` labelling made by a Trusted account and no edit to its title or body since then by an account that is not one, with no label that makes an **Unready Ticket** and no **Claim**, not a sub-issue, not a **Base fix**'s or a **Security run**'s fix issue, with no open blocker, either never started (no **Issue branch** and no pull request) or handed back at or after the last **Claim** ended, as recorded in its label history: by the **Day shift** applying `ready-for-agent`, or by thirdshift putting it back on a Spec whose Spec run ended only waiting, which it does only when no account that is not a Trusted account has edited the Spec since its last `ready-for-agent` labelling, and otherwise labels the Spec `needs-triage`, and only when each of its open Tickets in its repository passes the Trusted account check for Tickets, and otherwise labels it `agent-failed`, not a **Spec** whose **Tickets** are all closed, left untouched long enough that whoever is shaping it has finished, and, if it is a Spec, with a **Startable Ticket**. A hand-back made in the same edit as the Claim's removal counts in either listed order at that timestamp; a Claim applied at the same timestamp leaves the issue Claimed. On a Spec, the label says its Tickets are published. A `ready-for-agent` Ticket inside a Spec is not one: it is reached through its Spec, when the Spec is itself a Ready issue. +An open issue a **Pickup run** may take: labelled `ready-for-agent`, written by a **Trusted account**, with its most recent `ready-for-agent` labelling made by a Trusted account and no edit to its title or body since then by an account that is not one, with no label that makes an **Unready Ticket** and no **Claim**, not a sub-issue, not a **Base fix**'s or a **Security run**'s fix issue, with no open blocker, either never started (no **Issue branch** and no pull request) or handed back at or after the last **Claim** ended, as recorded in its label history: by the **Day shift** applying `ready-for-agent`, or by thirdshift putting it back on a Spec whose Spec run ended only waiting, which it does only when no account that is not a Trusted account has edited the Spec since its last `ready-for-agent` labelling, and otherwise labels the Spec `needs-triage`, and only when each of its open Tickets in its repository passes the Trusted account check for Tickets, and otherwise labels it `agent-failed`, left untouched long enough that whoever is shaping it has finished, and, if it is a **Spec**, with a **Startable Ticket** or with every Ticket closed and a resumable **Spec branch** on `origin` (the latest numbered branch, with no PR or an open PR). A hand-back made in the same edit as the Claim's removal counts in either listed order at that timestamp; a Claim applied at the same timestamp leaves the issue Claimed. On a Spec, the label says its Tickets are published. A `ready-for-agent` Ticket inside a Spec is not one: it is reached through its Spec, when the Spec is itself a Ready issue. _Avoid_: queued issue, backlog item **Trusted account**: @@ -37,7 +37,7 @@ The repository's owner, a member of the organization that owns it, or a collabor An open **Ticket** labelled with a triage role that says it is not agent work: `ready-for-human`, `needs-info`, `wontfix` or `needs-triage`. A **Spec run** never starts a **Ticket run** for one, nor for a Ticket it blocks. An open Ticket with no triage label is taken. A Ticket with sub-issues of its own is treated as one too. **Startable Ticket**: -An open **Ticket** in its **Spec**'s repository, with no sub-issues of its own, no open blocker, and not an **Unready Ticket**: one a **Spec run** could start if no Ticket were running. A Spec run starts only these, and a **Spec** with none is not a **Ready issue**. +An open **Ticket** in its **Spec**'s repository, with no sub-issues of its own, no open blocker, and not an **Unready Ticket**: one a **Spec run** could start if no Ticket were running. A Spec run starts only these, and a **Spec** with open Tickets but none startable is not a **Ready issue**; an all-closed Spec with a resumable **Spec branch** may be Ready for its **Spec review** and **Delivery**. **Claim**: The mark that a Run is live on an issue: thirdshift labels the issue `in-progress`, in place of `ready-for-agent` if it has that, when a **Ticket run**, a **Spec run** or a **Custom process** starts on it, whether started on its **Issue URL** or by an **Architect run**, a **Pickup run** or a **Security run**, or in place of its prior triage role and `agent-failed` when a **Triage run** starts on an existing issue. A **Ticket run** in a Spec run and a **Base fix** make none. It lasts only while the Run is live, and ending it leaves the issue marked with how the Run ended. A Run whose pull request is ready for review, or whose **Self-merge** was held with its pull request ready, takes it off and puts nothing back: the open pull request stands for the work. A Self-merge that closed the issue takes it off; one that merged without closing the issue leaves it until the issue is closed. A Failed run with work on `origin` swaps it for `agent-failed` (see **Failure limit**), as does a failed Security fix even with nothing on `origin`, so it stays with the Day shift. A Ticket run that fails with nothing on `origin` to take over, no **Issue branch** and no pull request, releases it, putting `ready-for-agent` back. A Spec run, or a **Custom process** landing a Spec's Tickets, whose undone **Tickets** are all only waiting, on a person or on other work, puts `ready-for-agent` back on its Spec without `agent-failed` (see **Ready issue**), unless it is a Security fix, which is swapped for `agent-failed`. A **Stop** leaves `needs-triage`, and a bug Ticket run that cannot reproduce the bug leaves `needs-info`. A Triage run on an existing issue replaces its Claim with the resulting triage role when it finishes, with `needs-triage` on a Stop asked to requeue, with `ready-for-human` on any other Stop, and with `ready-for-human` and `agent-failed` on other failure or interruption. A recommendation-only Triage run makes no Claim. Ending a Claim finishes even after Command interruption. @@ -128,7 +128,7 @@ An unscoped Fix **Pass** checks **Base branch** CI before selecting a pull reque One **Command** on an open pull request by a **Trusted account**, ready or draft, that takes its head branch through **Delivery** and the **Repair loop** against its **Base branch**, without repeating implementation or the initial code review. Started with no pull request, it is a **Pass** taking the lowest-numbered eligible open, non-draft, same-repository pull request targeting its selected Base branch with a **Merge conflict** or **CI red**, optionally scoped to a Trusted account with `--author`. It skips `in-progress` pull requests, pending CI without a Merge conflict, and green or absent CI without a Merge conflict. It reserves the pull request until it ends, and leaves it ready for review or completes a **Self-merge** when asked; a failure preserves its work and offers **Recovery** on that pull request. A title or body edit to a linked closing issue by an account that is not a **Trusted account**, made since a few minutes before the Fix run started, fails the run instead of starting the next Repair session. **Architect run**: -One invocation of the factory with no **Issue URL**: an **Architecture review** of the **Base branch**, then, unless asked to stop there, a **Spec run** or a **Ticket run** on the **Architect plan** it published. Its **Built-in process** owns the sequence of **Steps**, after the Pass has decided to go ahead. One pass per invocation. It reserves its plan across machines before marking it ready, as a **Pickup run** reserves its Ready issue, and holds the reservation through the dispatched run's ending. It is skipped, doing nothing, when the repository is at its **Command limit** on the machine or another Architect run is running there, when an earlier Architect plan is still open, when an **Architect idea** is waiting for triage, or when the repository has a **Ready issue** or an issue that meets every Ready issue condition except settling, including a **Spec** having a **Startable Ticket**, so that work a human shaped goes first. Skipped is not a failure, and sends no **Run notification**, so that it can be started every few minutes and so follow the last one as soon as its plan is merged. +One invocation of the factory with no **Issue URL**: an **Architecture review** of the **Base branch**, then, unless asked to stop there, a **Spec run** or a **Ticket run** on the **Architect plan** it published. Its **Built-in process** owns the sequence of **Steps**, after the Pass has decided to go ahead. One pass per invocation. It reserves its plan across machines before marking it ready, as a **Pickup run** reserves its Ready issue, and holds the reservation through the dispatched run's ending. It is skipped, doing nothing, when the repository is at its **Command limit** on the machine or another Architect run is running there, when an earlier Architect plan is still open, when an **Architect idea** is waiting for triage, or when the repository has a **Ready issue** or an issue that meets every Ready issue condition except settling, including a **Spec** having a **Startable Ticket** or an all-closed Spec with a resumable **Spec branch**, so that work a human shaped goes first. Skipped is not a failure, and sends no **Run notification**, so that it can be started every few minutes and so follow the last one as soon as its plan is merged. _Avoid_: Weeding (former name), improve run, architecture run **Pickup run**: diff --git a/docs/guide.md b/docs/guide.md index 5ad49ba91..399380d8a 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -3546,13 +3546,13 @@ Any triage decision releases the pause: take `needs-triage` off, whatever replac ### A Ready issue goes first -Work a human shaped goes ahead of the factory's own: while the repository has a **Ready issue**, or an issue that meets every Ready issue condition except settling, every Architect run on it is skipped, before any review. For a Spec, that includes having a Startable Ticket. A [Pickup run](#pickup-runs) still waits until the issue has settled before taking it. The check comes last, after the [Command limit](#the-command-limit), the [open Architect plan](#one-architect-plan-at-a-time) check and the [waiting Architect idea](#an-architect-idea-waits-for-triage) check, so with an open plan or a waiting idea as well, the skip reports that instead. It is only the search: an Architect run makes no [Sweep](#the-sweep) and holds no [Failure limit](#the-failure-limit), which are the Pickup run's. +Work a human shaped goes ahead of the factory's own: while the repository has a **Ready issue**, or an issue that meets every Ready issue condition except settling, every Architect run on it is skipped, before any review. A Spec qualifies with a Startable Ticket, or with every Ticket closed and a Spec branch on `origin` that can be continued under [ADR-0002](adr/0002-existing-issue-branch-means-continue.md): the latest branch has no PR or an open PR. Its hand-back and every other Ready issue condition still apply. A [Pickup run](#pickup-runs) still waits until the issue has settled before taking it. The check comes last, after the [Command limit](#the-command-limit), the [open Architect plan](#one-architect-plan-at-a-time) check and the [waiting Architect idea](#an-architect-idea-waits-for-triage) check, so with an open plan or a waiting idea as well, the skip reports that instead. It is only the search: an Architect run makes no [Sweep](#the-sweep) and holds no [Failure limit](#the-failure-limit), which are the Pickup run's. - **stdout** carries the URL of the lowest-numbered issue that is Ready or would be Ready once settled, whether it has settled or not. - **stderr** carries [a line on each issue labelled `ready-for-agent`](#why-an-issue-was-passed-over) passed over before it, then one progress line naming it: `Ready issue # "" goes first: <Issue URL>`, or, while it is settling, `Ready issue #<n> "<title>" goes first once it settles: <Issue URL>`. - **Exit code** `0`: skipped is not a failure. No agent session starts, and nothing else is done. -An issue passed over for any reason other than settling never skips an Architect run: one that carries a Claim, has an open blocker or an untrusted edit, is a Ticket inside a Spec, is a Spec with no Startable Ticket, or is a Base fix's issue, among the rest of the rule. Those get their lines on stderr all the same, and the Architect run goes on. With `pickup.wait_minutes = 0`, there is no settling wait and the check is unchanged. Security runs still check only for settled Ready issues. +An issue passed over for any reason other than settling never skips an Architect run: one that carries a Claim, has an open blocker or an untrusted edit, is a Ticket inside a Spec, is a Spec with open Tickets but no Startable Ticket, is an all-closed Spec without a resumable Spec branch, or is a Base fix's issue, among the rest of the rule. Those get their lines on stderr all the same, and the Architect run goes on. With `pickup.wait_minutes = 0`, there is no settling wait and the check is unchanged. Security runs still check only for settled Ready issues. The rule doesn't ask whether anything will take the Ready issue. A `ready-for-agent` issue that you mean to run by hand, on a repository with no Pickup line in its crontab, keeps every Architect run on it from starting until you run it, which makes the Claim, or take the label off. @@ -3748,9 +3748,9 @@ A Ready issue is an open issue that: - is not labelled `base-fix` or `security-fix`: the Ticket run that opened a [Base fix](#base-fix)'s issue, or the Security run that dispatched a fix, owns it. A failed Security fix is still passed over if GitHub prevented its Claim from being made; - has no open blocker, by GitHub's "blocked by" links, never the text of its body. Once every blocker is closed, it can be taken; - either was never started (no **Issue branch** on `origin` and no pull request from one, open, merged or closed), or you handed it back by applying `ready-for-agent` at or after its last [Claim](#the-claim) ended. A Spec whose Spec run ended only waiting is handed back by thirdshift the same way, unless its title or body was edited by an account that is not a Trusted account. For started work, the full label history must show an earlier `in-progress` application and its removal, with `ready-for-agent` applied at that removal's timestamp or later. A swap made in one edit counts in either listed order; a new Claim applied at the same timestamp leaves the issue Claimed. Work never Claimed, or without that hand-back, stays with you. An eligible branch with no PR or an open PR follows the existing [Continuation](#continuation) rule; merged or closed PRs follow ADR 0002 and start a fresh numbered branch. This is deliberate hand-back, not automatic retries; -- is not a **Spec** whose **Tickets** are all closed. With nothing started on it, such a Spec was done some other way, and the [Spec run](#spec-runs) it would be dispatched as stops with nothing to do, so a Pickup run passes it over rather than take it on every pass. Waiting never makes it a Ready issue, so this comes before the next condition, however lately the Spec was labelled: close it, or take its `ready-for-agent` off. A Spec with at least one open Ticket is taken if it meets the conditions below; +- if it is a **Spec** whose **Tickets** are all closed, its Spec run can continue its Spec branch on `origin` under [ADR-0002](adr/0002-existing-issue-branch-means-continue.md): the latest numbered branch is present, with no PR or an open PR. The dispatched Spec run goes straight to the Spec review and the Spec PR's Delivery and Repair loop, without running a Ticket again. A PR without its branch, or a retained branch whose latest PR is closed or merged, does not qualify. Without a resumable branch, waiting never makes it a Ready issue: close it, or take its `ready-for-agent` off; - is settled: by default, five minutes have passed since the latest of `ready-for-agent` being applied to it, a sub-issue being added to it or removed, and a "blocked by" link being added to it or removed. So a Spec is not taken while its Tickets are still being attached. These are read from the issue's timeline, since adding a sub-issue does not change the issue's update time, and `pickup.wait_minutes` in the [User config](#user-config) sets the window in whole minutes from 0 up, within the supported duration range; 0 disables waiting. An Architect run also waits for an issue that meets every other Ready issue condition while it is settling. A later pass takes the issue once it has settled; -- if it is a **Spec**, has a **Startable Ticket**: an open Ticket in the Spec's repository, with no sub-issues, no open blocker and none of the labels that make an Unready Ticket. That is the rule a [Spec run](#spec-runs) picks its next Ticket by, as if nothing were running yet, so a Spec whose open Tickets all wait on you, such as one whose last open Ticket is `needs-info`, is passed over rather than Claimed for a Spec run that could start nothing and would fail. This applies to a Spec handed back with `ready-for-agent` too. A Spec with one Startable Ticket is taken, and the Spec run handles its other Tickets as usual. Once a Ticket becomes startable, because its Unready label comes off or its blocker closes, a later pass takes the Spec. Its Tickets are read only for a Spec that meets every other condition. +- if it is a **Spec** with open Tickets, has a **Startable Ticket**: an open Ticket in the Spec's repository, with no sub-issues, no open blocker and none of the labels that make an Unready Ticket. That is the rule a [Spec run](#spec-runs) picks its next Ticket by, as if nothing were running yet, so a Spec whose open Tickets all wait on you, such as one whose last open Ticket is `needs-info`, is passed over rather than Claimed for a Spec run that could start nothing and would fail. This applies to a Spec handed back with `ready-for-agent` too. A Spec with one Startable Ticket is taken, and the Spec run handles its other Tickets as usual. Once a Ticket becomes startable, because its Unready label comes off or its blocker closes, a later pass takes the Spec. Its Tickets are read only for a Spec that meets every other condition. A Pickup run: @@ -3828,7 +3828,7 @@ thirdshift: 03:00:09 #38 has no Ticket that can start: #39 labelled needs-info; thirdshift: 03:00:09 no Ready issue on acme/widgets ``` -A line with `blocked by` names every open blocker. A Ticket's line names its Spec: the Ticket runs when its Spec does. A Ticket whose Spec is a Ready issue gets no line: its Spec is taken, by this pass or a later one. A started issue without a recorded hand-back gets the `started` line. Even with a hand-back, a Spec with every Ticket closed is passed over with `every Ticket is closed`, and one with no Startable Ticket with `has no Ticket that can start`, naming each open Ticket and why it can't start: the Unready label it has, the open issues blocking it, `has sub-issues` or `outside the Spec's repository`. When a later issue is a Ready issue, the lines on the earlier ones are printed and it is taken: +A line with `blocked by` names every open blocker. A Ticket's line names its Spec: the Ticket runs when its Spec does. A Ticket whose Spec is a Ready issue gets no line: its Spec is taken, by this pass or a later one. A started issue without a recorded hand-back gets the `started` line. Even with a hand-back, a Spec with every Ticket closed is passed over with `every Ticket is closed` unless its latest Spec branch on `origin` can be continued, with no PR or an open PR. A Spec whose later Ticket read is empty is still passed over. One with open Tickets but no Startable Ticket gets `has no Ticket that can start`, naming each open Ticket and why it can't start: the Unready label it has, the open issues blocking it, `has sub-issues` or `outside the Spec's repository`. When a later issue is a Ready issue, the lines on the earlier ones are printed and it is taken: ``` thirdshift: 03:00:01 Pickup run starting, 2026-10-03 -0400 diff --git a/site/docs/index.html b/site/docs/index.html index d1f89782f..f5b2fd017 100644 --- a/site/docs/index.html +++ b/site/docs/index.html @@ -3390,13 +3390,13 @@ </div></details> <details class="fold"><summary><h3>A Ready issue goes first</h3></summary> <div class="manual-content" id="a-ready-issue-goes-first"> -<p>Work a human shaped goes ahead of the factory's own: while the repository has a <strong>Ready issue</strong>, or an issue that meets every Ready issue condition except settling, every Architect run on it is skipped, before any review. For a Spec, that includes having a Startable Ticket. A <a href="#pickup-runs">Pickup run</a> still waits until the issue has settled before taking it. The check comes last, after the <a href="#the-command-limit">Command limit</a>, the <a href="#one-architect-plan-at-a-time">open Architect plan</a> check and the <a href="#an-architect-idea-waits-for-triage">waiting Architect idea</a> check, so with an open plan or a waiting idea as well, the skip reports that instead. It is only the search: an Architect run makes no <a href="#the-sweep">Sweep</a> and holds no <a href="#the-failure-limit">Failure limit</a>, which are the Pickup run's.</p> +<p>Work a human shaped goes ahead of the factory's own: while the repository has a <strong>Ready issue</strong>, or an issue that meets every Ready issue condition except settling, every Architect run on it is skipped, before any review. A Spec qualifies with a Startable Ticket, or with every Ticket closed and a Spec branch on <code>origin</code> that can be continued under <a href="https://github.com/JacobStephens2/thirdshift/blob/main/docs/adr/0002-existing-issue-branch-means-continue.md">ADR-0002</a>: the latest branch has no PR or an open PR. Its hand-back and every other Ready issue condition still apply. A <a href="#pickup-runs">Pickup run</a> still waits until the issue has settled before taking it. The check comes last, after the <a href="#the-command-limit">Command limit</a>, the <a href="#one-architect-plan-at-a-time">open Architect plan</a> check and the <a href="#an-architect-idea-waits-for-triage">waiting Architect idea</a> check, so with an open plan or a waiting idea as well, the skip reports that instead. It is only the search: an Architect run makes no <a href="#the-sweep">Sweep</a> and holds no <a href="#the-failure-limit">Failure limit</a>, which are the Pickup run's.</p> <ul> <li><strong>stdout</strong> carries the URL of the lowest-numbered issue that is Ready or would be Ready once settled, whether it has settled or not.</li> <li><strong>stderr</strong> carries <a href="#why-an-issue-was-passed-over">a line on each issue labelled <code>ready-for-agent</code></a> passed over before it, then one progress line naming it: <code>Ready issue #<n> "<title>" goes first: <Issue URL></code>, or, while it is settling, <code>Ready issue #<n> "<title>" goes first once it settles: <Issue URL></code>.</li> <li><strong>Exit code</strong> <code>0</code>: skipped is not a failure. No agent session starts, and nothing else is done.</li> </ul> -<p>An issue passed over for any reason other than settling never skips an Architect run: one that carries a Claim, has an open blocker or an untrusted edit, is a Ticket inside a Spec, is a Spec with no Startable Ticket, or is a Base fix's issue, among the rest of the rule. Those get their lines on stderr all the same, and the Architect run goes on. With <code>pickup.wait_minutes = 0</code>, there is no settling wait and the check is unchanged. Security runs still check only for settled Ready issues.</p> +<p>An issue passed over for any reason other than settling never skips an Architect run: one that carries a Claim, has an open blocker or an untrusted edit, is a Ticket inside a Spec, is a Spec with open Tickets but no Startable Ticket, is an all-closed Spec without a resumable Spec branch, or is a Base fix's issue, among the rest of the rule. Those get their lines on stderr all the same, and the Architect run goes on. With <code>pickup.wait_minutes = 0</code>, there is no settling wait and the check is unchanged. Security runs still check only for settled Ready issues.</p> <p>The rule doesn't ask whether anything will take the Ready issue. A <code>ready-for-agent</code> issue that you mean to run by hand, on a repository with no Pickup line in its crontab, keeps every Architect run on it from starting until you run it, which makes the Claim, or take the label off.</p> </div></details> <details class="fold"><summary><h3>The Command limit</h3></summary> @@ -3535,9 +3535,9 @@ <li>is not labelled <code>base-fix</code> or <code>security-fix</code>: the Ticket run that opened a <a href="#base-fix">Base fix</a>'s issue, or the Security run that dispatched a fix, owns it. A failed Security fix is still passed over if GitHub prevented its Claim from being made;</li> <li>has no open blocker, by GitHub's "blocked by" links, never the text of its body. Once every blocker is closed, it can be taken;</li> <li>either was never started (no <strong>Issue branch</strong> on <code>origin</code> and no pull request from one, open, merged or closed), or you handed it back by applying <code>ready-for-agent</code> at or after its last <a href="#the-claim">Claim</a> ended. A Spec whose Spec run ended only waiting is handed back by thirdshift the same way, unless its title or body was edited by an account that is not a Trusted account. For started work, the full label history must show an earlier <code>in-progress</code> application and its removal, with <code>ready-for-agent</code> applied at that removal's timestamp or later. A swap made in one edit counts in either listed order; a new Claim applied at the same timestamp leaves the issue Claimed. Work never Claimed, or without that hand-back, stays with you. An eligible branch with no PR or an open PR follows the existing <a href="#continuation">Continuation</a> rule; merged or closed PRs follow ADR 0002 and start a fresh numbered branch. This is deliberate hand-back, not automatic retries;</li> -<li>is not a <strong>Spec</strong> whose <strong>Tickets</strong> are all closed. With nothing started on it, such a Spec was done some other way, and the <a href="#spec-runs">Spec run</a> it would be dispatched as stops with nothing to do, so a Pickup run passes it over rather than take it on every pass. Waiting never makes it a Ready issue, so this comes before the next condition, however lately the Spec was labelled: close it, or take its <code>ready-for-agent</code> off. A Spec with at least one open Ticket is taken if it meets the conditions below;</li> +<li>if it is a <strong>Spec</strong> whose <strong>Tickets</strong> are all closed, its Spec run can continue its Spec branch on <code>origin</code> under <a href="https://github.com/JacobStephens2/thirdshift/blob/main/docs/adr/0002-existing-issue-branch-means-continue.md">ADR-0002</a>: the latest numbered branch is present, with no PR or an open PR. The dispatched Spec run goes straight to the Spec review and the Spec PR's Delivery and Repair loop, without running a Ticket again. A PR without its branch, or a retained branch whose latest PR is closed or merged, does not qualify. Without a resumable branch, waiting never makes it a Ready issue: close it, or take its <code>ready-for-agent</code> off;</li> <li>is settled: by default, five minutes have passed since the latest of <code>ready-for-agent</code> being applied to it, a sub-issue being added to it or removed, and a "blocked by" link being added to it or removed. So a Spec is not taken while its Tickets are still being attached. These are read from the issue's timeline, since adding a sub-issue does not change the issue's update time, and <code>pickup.wait_minutes</code> in the <a href="#user-config">User config</a> sets the window in whole minutes from 0 up, within the supported duration range; 0 disables waiting. An Architect run also waits for an issue that meets every other Ready issue condition while it is settling. A later pass takes the issue once it has settled;</li> -<li>if it is a <strong>Spec</strong>, has a <strong>Startable Ticket</strong>: an open Ticket in the Spec's repository, with no sub-issues, no open blocker and none of the labels that make an Unready Ticket. That is the rule a <a href="#spec-runs">Spec run</a> picks its next Ticket by, as if nothing were running yet, so a Spec whose open Tickets all wait on you, such as one whose last open Ticket is <code>needs-info</code>, is passed over rather than Claimed for a Spec run that could start nothing and would fail. This applies to a Spec handed back with <code>ready-for-agent</code> too. A Spec with one Startable Ticket is taken, and the Spec run handles its other Tickets as usual. Once a Ticket becomes startable, because its Unready label comes off or its blocker closes, a later pass takes the Spec. Its Tickets are read only for a Spec that meets every other condition.</li> +<li>if it is a <strong>Spec</strong> with open Tickets, has a <strong>Startable Ticket</strong>: an open Ticket in the Spec's repository, with no sub-issues, no open blocker and none of the labels that make an Unready Ticket. That is the rule a <a href="#spec-runs">Spec run</a> picks its next Ticket by, as if nothing were running yet, so a Spec whose open Tickets all wait on you, such as one whose last open Ticket is <code>needs-info</code>, is passed over rather than Claimed for a Spec run that could start nothing and would fail. This applies to a Spec handed back with <code>ready-for-agent</code> too. A Spec with one Startable Ticket is taken, and the Spec run handles its other Tickets as usual. Once a Ticket becomes startable, because its Unready label comes off or its blocker closes, a later pass takes the Spec. Its Tickets are read only for a Spec that meets every other condition.</li> </ul> <p>A Pickup run:</p> <ol> @@ -3604,7 +3604,7 @@ thirdshift: 03:00:09 #38 has no Ticket that can start: #39 labelled needs-info; #40 blocked by #39; #41 blocked by #12 (outside the Spec) thirdshift: 03:00:09 no Ready issue on acme/widgets </code></pre> -<p>A line with <code>blocked by</code> names every open blocker. A Ticket's line names its Spec: the Ticket runs when its Spec does. A Ticket whose Spec is a Ready issue gets no line: its Spec is taken, by this pass or a later one. A started issue without a recorded hand-back gets the <code>started</code> line. Even with a hand-back, a Spec with every Ticket closed is passed over with <code>every Ticket is closed</code>, and one with no Startable Ticket with <code>has no Ticket that can start</code>, naming each open Ticket and why it can't start: the Unready label it has, the open issues blocking it, <code>has sub-issues</code> or <code>outside the Spec's repository</code>. When a later issue is a Ready issue, the lines on the earlier ones are printed and it is taken:</p> +<p>A line with <code>blocked by</code> names every open blocker. A Ticket's line names its Spec: the Ticket runs when its Spec does. A Ticket whose Spec is a Ready issue gets no line: its Spec is taken, by this pass or a later one. A started issue without a recorded hand-back gets the <code>started</code> line. Even with a hand-back, a Spec with every Ticket closed is passed over with <code>every Ticket is closed</code> unless its latest Spec branch on <code>origin</code> can be continued, with no PR or an open PR. A Spec whose later Ticket read is empty is still passed over. One with open Tickets but no Startable Ticket gets <code>has no Ticket that can start</code>, naming each open Ticket and why it can't start: the Unready label it has, the open issues blocking it, <code>has sub-issues</code> or <code>outside the Spec's repository</code>. When a later issue is a Ready issue, the lines on the earlier ones are printed and it is taken:</p> <pre><code>thirdshift: 03:00:01 Pickup run starting, 2026-10-03 -0400 thirdshift: 03:00:02 #18 labelled needs-info thirdshift: 03:00:03 #20 blocked by #17 diff --git a/site/how-it-works/index.html b/site/how-it-works/index.html index 9d7832a96..fe0c84579 100644 --- a/site/how-it-works/index.html +++ b/site/how-it-works/index.html @@ -236,7 +236,7 @@ <h1>How thirdshift works</h1> </ol> </figure> <p>Each Ticket gets a Ticket run that Self-merges into the <strong>Spec branch</strong>, not directly into <code>main</code>. The <strong>Spec PR</strong> gathers the result into the Base branch. A Ticket closing means its work landed on the Spec branch; the whole Spec has not shipped yet.</p> - <p>A failed Ticket stops only its dependents; other reachable Tickets can still run. Tickets marked for human work or triage also hold their dependents. An incomplete Spec leaves a draft Spec PR with a Tickets checklist. A Spec run that is only waiting, with no failed or interrupted Ticket and every undone Ticket gated, blocked behind gated Tickets or outside work, or outside the Spec's repository, puts <code>ready-for-agent</code> back on its Spec instead of <code>agent-failed</code>, unless an account that is not a Trusted account edited the Spec since its last Trusted labelling, which leaves <code>needs-triage</code>, and comments on the Spec naming each undone Ticket and what it waits on. Pickup continues it once a Ticket can start; a blocking cycle is a failure. A Spec PR closed, merged or retargeted mid-run starts no further Ticket, holds the Self-merge of a running Ticket started after it opened, and ends the Spec run without a Recovery command. An Unready Ticket's checklist and Command log line name its open blockers, marking those outside the Spec and those labelled <code>ready-for-agent</code>; outside blockers are reported but never run. Rerunning continues existing branches. <a href="/docs/#spec-runs">Spec run controls and limits</a>.</p> + <p>A failed Ticket stops only its dependents; other reachable Tickets can still run. Tickets marked for human work or triage also hold their dependents. An incomplete Spec leaves a draft Spec PR with a Tickets checklist. A Spec run that is only waiting, with no failed or interrupted Ticket and every undone Ticket gated, blocked behind gated Tickets or outside work, or outside the Spec's repository, puts <code>ready-for-agent</code> back on its Spec instead of <code>agent-failed</code>, unless an account that is not a Trusted account edited the Spec since its last Trusted labelling, which leaves <code>needs-triage</code>, and comments on the Spec naming each undone Ticket and what it waits on. Pickup continues it once a Ticket can start; a blocking cycle is a failure. A handed-back Spec with every Ticket closed can also be taken when its latest Spec branch is on <code>origin</code>, with no PR or an open PR: it goes straight to Spec review and Delivery without running a Ticket again. Its usual Ready issue gates still apply; Architect gives it priority even while it settles. A Spec PR closed, merged or retargeted mid-run starts no further Ticket, holds the Self-merge of a running Ticket started after it opened, and ends the Spec run without a Recovery command. An Unready Ticket's checklist and Command log line name its open blockers, marking those outside the Spec and those labelled <code>ready-for-agent</code>; outside blockers are reported but never run. Rerunning continues existing branches. <a href="/docs/#spec-runs">Spec run controls and limits</a>.</p> </div> </details> </section> diff --git a/src/branch.rs b/src/branch.rs index 88db4613e..720cec135 100644 --- a/src/branch.rs +++ b/src/branch.rs @@ -89,6 +89,17 @@ pub fn started(launch: &Git, github: &GitHub, issue: &IssueUrl) -> Result<Option started_through(&GitHubAndOrigin { launch, github }, issue) } +/// Whether ADR-0002 allows continuing the latest Issue branch: it is on +/// origin and its newest PR is open, or it has no PR. Unlike `select`, this +/// readiness read neither checks local branches nor acquires a Worktree. +pub fn can_continue(launch: &Git, github: &GitHub, issue: &IssueUrl) -> Result<bool> { + Ok( + latest(&GitHubAndOrigin { launch, github }, issue)?.is_some_and(|latest| { + latest.origin_sha.is_some() && latest.pr.is_none_or(|pr| pr.state == PrState::Open) + }), + ) +} + /// What selection reads of origin, GitHub and the Launch directory. Each /// listing is by prefix, as git and GitHub answer it, so it can hold another /// issue's Issue branches. @@ -151,14 +162,12 @@ impl Reads for GitHubAndOrigin<'_> { /// [`select`], reading through `reads`. fn select_through(reads: &impl Reads, issue: &IssueUrl) -> Result<Selection> { let first_branch = branch_name(issue, 1); - let Used { on_origin, prs } = used(reads, issue)?; - - let highest = on_origin - .iter() - .map(|(number, _)| *number) - .chain(prs.iter().map(|(number, _)| *number)) - .max(); - let Some(highest) = highest else { + let Some(Latest { + number: highest, + origin_sha, + pr, + }) = latest(reads, issue)? + else { worktree::check_local_branch( &first_branch, reads.local_head(&first_branch)?.as_deref(), @@ -169,14 +178,6 @@ fn select_through(reads: &impl Reads, issue: &IssueUrl) -> Result<Selection> { }); }; let branch = branch_name(issue, highest); - // A branch can have had several PRs; the newest one decides. - let pr = prs - .into_iter() - .filter_map(|(number, pr)| (number == highest).then_some(pr)) - .max_by_key(|pr| pr.number); - let origin_sha = on_origin - .into_iter() - .find_map(|(number, sha)| (number == highest).then_some(sha)); match (origin_sha, pr) { // A merged or closed PR uses its number up, even if its branch was deleted. (_, Some(pr)) if pr.state != PrState::Open => { @@ -196,6 +197,37 @@ fn select_through(reads: &impl Reads, issue: &IssueUrl) -> Result<Selection> { } } +/// The highest-numbered Issue branch ever used, with its current origin +/// head and newest PR. Readiness and selection use the same remote facts. +struct Latest { + number: u64, + origin_sha: Option<String>, + pr: Option<PullRequest>, +} + +fn latest(reads: &impl Reads, issue: &IssueUrl) -> Result<Option<Latest>> { + let Used { on_origin, prs } = used(reads, issue)?; + let Some(highest) = on_origin + .iter() + .map(|(number, _)| *number) + .chain(prs.iter().map(|(number, _)| *number)) + .max() + else { + return Ok(None); + }; + Ok(Some(Latest { + number: highest, + origin_sha: on_origin + .into_iter() + .find_map(|(number, sha)| (number == highest).then_some(sha)), + // A branch can have had several PRs; the newest one decides. + pr: prs + .into_iter() + .filter_map(|(number, pr)| (number == highest).then_some(pr)) + .max_by_key(|pr| pr.number), + })) +} + /// [`started`], reading through `reads`. fn started_through(reads: &impl Reads, issue: &IssueUrl) -> Result<Option<Started>> { let Used { on_origin, prs } = used(reads, issue)?; diff --git a/src/ready.rs b/src/ready.rs index 85d7a1983..555981397 100644 --- a/src/ready.rs +++ b/src/ready.rs @@ -79,7 +79,7 @@ pub struct ReadyIssue { pub enum PriorityIssue { Ready(ReadyIssue), /// Every other Ready issue condition holds, including a Spec having a - /// Startable Ticket. Pickup still cannot take it. + /// Startable Ticket or an all-closed continuation. Pickup still waits. Settling(ListedIssue), } @@ -191,6 +191,8 @@ trait Reads { fn candidate(&self, issue: &IssueUrl) -> Result<Candidate>; /// What shows `issue` was ever started, if it was. fn started(&self, issue: &IssueUrl) -> Result<Option<Started>>; + /// Whether the latest Issue branch can be continued under ADR-0002. + fn can_continue(&self, issue: &IssueUrl) -> Result<bool>; /// The Tickets of `spec`, an issue with sub-issues, as a Spec run reads /// them. fn tickets(&self, spec: &IssueUrl) -> Result<Vec<Ticket>>; @@ -216,6 +218,10 @@ impl Reads for GitHubAndOrigin<'_> { branch::started(self.launch, &self.github, issue) } + fn can_continue(&self, issue: &IssueUrl) -> Result<bool> { + branch::can_continue(self.launch, &self.github, issue) + } + fn tickets(&self, spec: &IssueUrl) -> Result<Vec<Ticket>> { self.github.tickets(spec) } @@ -495,10 +501,10 @@ fn why_none_can_start(spec: &IssueUrl, tickets: &[Ticket]) -> String { /// Trusted accounts, it has no label that makes an Unready Ticket and no /// Claim, is not a sub-issue, has no `base-fix` or `security-fix` label and no /// open blocker, was never started or has a recorded ready-for-agent -/// hand-back after its last Claim ended, is not a Spec whose Tickets are all -/// closed, is settled: `wait` has passed since it was last labelled -/// `ready-for-agent` and since a sub-issue or a "blocked by" link of its was -/// last added or removed, and, if it is a Spec, has a startable Ticket. Its +/// hand-back after its last Claim ended, is settled: `wait` has passed since +/// it was last labelled `ready-for-agent` and since a sub-issue or a "blocked by" link of its was +/// last added or removed, and, if it is a Spec, has a Startable Ticket or +/// every Ticket closed with a Spec branch that can be continued. Its /// Tickets are read only for that last check. fn standing_of( reads: &impl Reads, @@ -538,9 +544,14 @@ fn standing_of( if read.claimed { return passed_over(Reason::Claimed); } - if spec_run::all_closed(read.sub_issue_is_open.iter().copied()) { - return passed_over(Reason::TicketsClosed); - } + let continuing_closed_spec = if spec_run::all_closed(read.sub_issue_is_open.iter().copied()) { + if !reads.can_continue(&candidate.issue)? { + return passed_over(Reason::TicketsClosed); + } + true + } else { + false + }; let unsettled = read .last_shaped .filter(|shaped| wait > TimeDelta::zero() && now - shaped.at < wait); @@ -557,9 +568,12 @@ fn standing_of( let tickets = reads.tickets(&candidate.issue)?; // Read after the candidate: its Tickets may have closed since. if !tickets.iter().any(|ticket| ticket.is_open) { - return passed_over(Reason::TicketsClosed); - } - if tickets + if tickets.is_empty() + || !(continuing_closed_spec || reads.can_continue(&candidate.issue)?) + { + return passed_over(Reason::TicketsClosed); + } + } else if tickets .iter() .all(|ticket| spec_run::unstartable(&candidate.issue, ticket).is_some()) { @@ -664,6 +678,10 @@ mod tests { Ok(self.facts(issue).and_then(|facts| facts.started.clone())) } + fn can_continue(&self, _issue: &IssueUrl) -> Result<bool> { + Ok(false) + } + fn tickets(&self, spec: &IssueUrl) -> Result<Vec<Ticket>> { self.seen.borrow_mut().push(Seen::TicketsRead(spec.number)); let facts = self.facts(spec).expect("only a Spec's Tickets are read"); diff --git a/tests/architect_run.rs b/tests/architect_run.rs index c8e2e4fcf..c089271c6 100644 --- a/tests/architect_run.rs +++ b/tests/architect_run.rs @@ -2696,6 +2696,61 @@ fn the_lowest_numbered_ready_or_settling_issue_goes_first() { } } +#[test] +fn an_all_closed_spec_has_architect_priority_only_with_a_handed_back_continuation() { + for (branch, handed_back, ready_ago) in [ + (true, true, 60), + (true, true, 4), + (false, true, 60), + (false, true, 4), + (true, false, 60), + ] { + let scenario = scenario(); + ready_issue(&scenario, 7, "Finish the Spec review", &[]); + scenario.spec_has_tickets(7, &[(9, &[])]); + scenario.issue_is(9, "CLOSED"); + if branch { + scenario.origin_has_branch("issue-7", "main", &["Landed Tickets"]); + } + let mut timeline = Vec::new(); + if handed_back { + timeline.extend([ + (TimelineEvent::Labelled(IN_PROGRESS), 90), + (TimelineEvent::Unlabelled(IN_PROGRESS), 61), + ]); + } + timeline.push((TimelineEvent::Labelled("ready-for-agent"), ready_ago)); + scenario.issue_timeline(7, &timeline); + scenario.agent_does(AGENT_PUBLISHES_A_TICKET); + let github = scenario.gh_state(); + + let result = scenario.run(&["architect", "--plan-only"]); + + assert_eq!(result.code, Some(0), "{}", result.stderr); + if branch && handed_back { + assert_eq!(result.stdout, format!("{}\n", scenario.issue_url(7))); + let reason = if ready_ago < 5 { + format!( + "Ready issue #7 \"Finish the Spec review\" goes first once it settles: {}", + scenario.issue_url(7) + ) + } else { + goes_first(&scenario, 7, "Finish the Spec review") + }; + assert_eq!( + after_start(&result.stderr), + format!("thirdshift: {reason}\n") + ); + assert!(scenario.claude_calls().is_empty()); + assert_eq!(scenario.gh_state(), github); + assert_nothing_left_behind(&scenario); + } else { + assert_eq!(result.stdout, format!("{}\n", scenario.issue_url(10))); + assert_eq!(scenario.claude_calls().len(), 1); + } + } +} + #[test] fn a_settling_spec_goes_first_only_with_a_startable_ticket() { for startable in [false, true] { diff --git a/tests/pickup_run.rs b/tests/pickup_run.rs index 2625fb533..b23a6e1a8 100644 --- a/tests/pickup_run.rs +++ b/tests/pickup_run.rs @@ -3,7 +3,7 @@ //! that makes an Unready Ticket, no Claim and no `base-fix` label, that is //! not a sub-issue, has no open blocker and was never started or was handed //! back after its last Claim ended, is not a -//! Spec whose Tickets are all closed, and has settled, and +//! Spec whose Tickets are all closed without a resumable Spec branch, and has settled, and //! dispatches it as `thirdshift <Issue URL>` would, a Spec run or a Run, //! ending as that does. Each `ready-for-agent` issue it passes over on the //! way gets a line saying why. With no Ready issue, with the repository at @@ -1355,6 +1355,247 @@ fn spec_with_every_ticket_closed() -> Scenario { scenario } +fn hand_back_spec(scenario: &Scenario) { + scenario.issue_timeline( + 7, + &[ + (TimelineEvent::Labelled(IN_PROGRESS), 90), + (TimelineEvent::Unlabelled(IN_PROGRESS), 61), + (TimelineEvent::Labelled(READY_FOR_AGENT), 60), + ], + ); +} + +#[test] +fn pickup_continues_an_all_closed_spec_through_review_and_delivery() { + for open_pr in [false, true] { + let scenario = spec_with_every_ticket_closed(); + scenario.origin_has_branch("issue-7", "main", &["Landed Tickets"]); + let existing_pr = open_pr.then(|| scenario.github_has_pr("issue-7", "main", "OPEN")); + hand_back_spec(&scenario); + + let result = scenario.run(&["pickup"]); + + let pr = pr_from(&scenario, "issue-7"); + assert_ended_with_pr(&result, &pr, "ready for review"); + assert_eq!(sessions(&scenario), ["7"], "no Ticket may run again"); + assert_eq!(scenario.claude_calls()[0]["branch"], "issue-7"); + assert_eq!(pr["isDraft"], false); + if let Some(url) = existing_pr { + assert_eq!(pr["url"], url); + } + assert_eq!(scenario.issue_labels(7), NO_LABELS); + assert_eq!( + scenario.origin_git(&["for-each-ref", "refs/heads/thirdshift/pickup"]), + "" + ); + assert!( + result.stderr.contains("labelling #7 in-progress"), + "{}", + result.stderr + ); + } +} + +#[test] +fn an_all_closed_spec_continues_its_latest_numbered_branch() { + let scenario = spec_with_every_ticket_closed(); + scenario.origin_has_branch("issue-7", "main", &["First attempt"]); + scenario.github_has_pr("issue-7", "main", "MERGED"); + scenario.origin_has_branch("issue-7-branch-3", "main", &["Landed Tickets"]); + let url = scenario.github_has_pr("issue-7-branch-3", "main", "OPEN"); + hand_back_spec(&scenario); + + let result = scenario.run(&["pickup"]); + + assert_ended_with_pr( + &result, + &pr_from(&scenario, "issue-7-branch-3"), + "ready for review", + ); + assert_eq!(result.stdout, format!("{url}\n")); + assert_eq!(sessions(&scenario), ["7"]); + assert_eq!(scenario.claude_calls()[0]["branch"], "issue-7-branch-3"); +} + +#[test] +fn an_all_closed_spec_still_requires_a_resumable_latest_branch_and_a_hand_back() { + for case in [ + "no branch", + "PR only", + "CLOSED", + "MERGED", + "newer finished", + "no hand-back", + ] { + let scenario = spec_with_every_ticket_closed(); + if case != "no branch" && case != "PR only" { + scenario.origin_has_branch("issue-7", "main", &["Landed Tickets"]); + } + match case { + "PR only" => { + scenario.github_has_pr("issue-7", "main", "OPEN"); + } + "CLOSED" | "MERGED" => { + scenario.github_has_pr("issue-7", "main", "OPEN"); + scenario.github_has_pr("issue-7", "main", case); + } + "newer finished" => { + scenario.github_has_pr("issue-7", "main", "OPEN"); + scenario.github_has_pr("issue-7-branch-2", "main", "MERGED"); + } + _ => {} + } + if case != "no hand-back" { + hand_back_spec(&scenario); + } + let github = scenario.gh_state(); + + let result = scenario.run(&["pickup"]); + + let reason = if case == "no hand-back" { + "#7 started: issue-7 is on origin; no ready-for-agent hand-back after the last Claim ended" + } else { + EVERY_TICKET_CLOSED + }; + assert_skipped(&scenario, &result, &no_ready_issue_after(&[reason])); + assert_eq!(scenario.gh_state(), github, "{case}"); + } +} + +/// Change GitHub after the candidate response has been sampled, so the +/// following Tickets read sees a different state through the real CLI. +fn after_spec_candidate(scenario: &Scenario, change: &str) { + fs::create_dir_all(scenario.path("bin/real")).unwrap(); + fs::rename(scenario.path("bin/gh"), scenario.path("bin/real/gh")).unwrap(); + write_executable( + &scenario.path("bin/gh"), + &format!( + r#"#!/bin/sh +real="$(dirname "$0")/real/gh" +case "$*" in +'api graphql '*timelineItems*) + "$real" "$@" > "$TMPDIR/spec-candidate" || exit $? + if [ ! -f "$TMPDIR/spec-changed" ]; then + touch "$TMPDIR/spec-changed" + {change} + fi + cat "$TMPDIR/spec-candidate" + exit 0 + ;; +esac +exec "$real" "$@" +"# + ), + ); +} + +#[test] +fn tickets_closing_between_reads_or_after_reservation_follow_the_continuation_rule() { + for after_reservation in [false, true] { + for branch in [false, true] { + let scenario = spec_with_every_ticket_closed(); + scenario.issue_is(5, "OPEN"); + scenario.issue_is(6, "OPEN"); + if branch { + scenario.origin_has_branch("issue-7", "main", &["Landed Tickets"]); + hand_back_spec(&scenario); + } + if after_reservation { + install_git_wrapper(&scenario, |git| { + format!( + r#"#!/bin/sh +"{git}" "$@" || exit $? +case "$*" in +push*' '[0-9a-f]*:refs/heads/thirdshift/pickup/7) + gh fake issue 5 CLOSED + gh fake issue 6 CLOSED + ;; +esac +"# + ) + }); + } else { + after_spec_candidate( + &scenario, + "\"$real\" fake issue 5 CLOSED\n\"$real\" fake issue 6 CLOSED", + ); + } + + let result = scenario.run(&["pickup"]); + + if branch { + assert_ended_with_pr(&result, &pr_from(&scenario, "issue-7"), "ready for review"); + assert_eq!(sessions(&scenario), ["7"]); + } else { + assert_eq!(result.code, Some(0), "{}", result.stderr); + assert!(scenario.claude_calls().is_empty(), "a session was started"); + let reason = if after_reservation { + unreservable(7) + } else { + format!("thirdshift: {EVERY_TICKET_CLOSED}\n") + }; + assert!(result.stderr.contains(&reason), "{}", result.stderr); + assert_eq!(scenario.issue_labels(7), [READY_FOR_AGENT]); + } + assert_eq!( + scenario.origin_git(&["for-each-ref", "refs/heads/thirdshift/pickup"]), + "" + ); + } + } +} + +#[test] +fn an_all_closed_spec_with_an_empty_later_ticket_read_is_still_passed_over() { + let scenario = spec_with_every_ticket_closed(); + scenario.origin_has_branch("issue-7", "main", &["Landed Tickets"]); + hand_back_spec(&scenario); + after_spec_candidate(&scenario, "\"$real\" fake sub-issues 7 '[]'"); + + let result = scenario.run(&["pickup"]); + + assert_skipped( + &scenario, + &result, + &no_ready_issue_after(&[EVERY_TICKET_CLOSED]), + ); + assert_eq!(scenario.issue_labels(7), [READY_FOR_AGENT]); +} + +#[test] +fn an_all_closed_spec_losing_its_branch_after_reservation_is_not_dispatched() { + let scenario = spec_with_every_ticket_closed(); + scenario.origin_has_branch("issue-7", "main", &["Landed Tickets"]); + hand_back_spec(&scenario); + install_git_wrapper(&scenario, |git| { + format!( + r#"#!/bin/sh +"{git}" "$@" || exit $? +case "$*" in +push*' '[0-9a-f]*:refs/heads/thirdshift/pickup/7) + "{git}" push origin :refs/heads/issue-7 ;; +esac +"# + ) + }); + + let result = scenario.run(&["pickup"]); + + assert_eq!(result.code, Some(0), "{}", result.stderr); + assert!(scenario.claude_calls().is_empty()); + assert!( + result.stderr.contains(&unreservable(7)), + "{}", + result.stderr + ); + assert_eq!(scenario.issue_labels(7), [READY_FOR_AGENT]); + assert_eq!( + scenario.origin_git(&["for-each-ref", "refs/heads/thirdshift/pickup"]), + "" + ); +} + #[test] fn a_spec_whose_tickets_are_all_closed_is_passed_over_and_the_next_ready_issue_is_taken() { let scenario = spec_with_every_ticket_closed();