Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ parallel = 2 # how many Tickets a Spec run runs at once, instead of 3

[pickup]
limit = 5 # how many open issues labelled in-progress stop a Pickup run taking another, instead of 3
wait_minutes = 60 # minutes since the latest shaping event before an issue is Ready, instead of 30; 0 disables waiting

[harness]
default = "claude" # the Harness every session runs on; claude unless set
Expand Down Expand Up @@ -246,6 +247,7 @@ parallel = 3 # how many Tickets a Spec run runs at once; default 3

[pickup]
limit = 3 # how many open issues labelled in-progress stop a Pickup run taking another; default 3
wait_minutes = 30 # minutes since the latest shaping event before an issue is Ready; 0 disables waiting; default 30

[harness]
default = "claude" # the Harness every Run's sessions run on, claude, codex, agy, grok, muse or opencode; default claude
Expand Down Expand Up @@ -646,7 +648,7 @@ Work a human shaped goes ahead of the factory's own: while the repository has a
- **stderr** carries, as a Pickup run's does, [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 #<n> "<title>" goes first: <Issue URL>`.
- **Exit code** `0`: skipped is not a failure. No agent session starts, and nothing else is done.

An issue labelled `ready-for-agent` that is not a Ready issue never skips an Architect run: one that carries a Claim, was labelled or shaped less than ten minutes ago, has an open blocker, is a Ticket inside a Spec that is not itself a Ready issue, 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.
An issue labelled `ready-for-agent` that is not a Ready issue never skips an Architect run: one that carries a Claim, was labelled or shaped within the configured settling window (thirty minutes by default), has an open blocker, is a Ticket inside a Spec that is not itself a Ready issue, 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.

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.

Expand Down Expand Up @@ -748,7 +750,7 @@ A Ready issue is an open issue that:
- 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;
- was never started: no **Issue branch** for it is on `origin`, and no pull request from one exists, open, merged or closed. So a Pickup run never does a [Continuation](#continuation), and an issue whose Run failed waits for you;
- 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;
- is settled: ten 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 the ten minutes is fixed, with no setting. A later pass takes the issue once it has settled.
- is settled: by default, thirty 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. The setting also controls this same Ready issue search for Architect runs. A later pass takes the issue once it has settled.

A Pickup run:

Expand Down Expand Up @@ -799,9 +801,9 @@ thirdshift: 03:00:04 #30 blocked by #29
thirdshift: 03:00:05 #31 already started: issue-31 is on origin
thirdshift: 03:00:06 #32 already started: PR https://github.com/acme/widgets/pull/37
thirdshift: 03:00:06 #33 every Ticket is closed
thirdshift: 03:00:07 #34 not settled: labelled ready-for-agent less than 10 minutes ago
thirdshift: 03:00:07 #35 not settled: a sub-issue added or removed less than 10 minutes ago
thirdshift: 03:00:08 #36 not settled: a "blocked by" link added or removed less than 10 minutes ago
thirdshift: 03:00:07 #34 not settled: labelled ready-for-agent less than 30 minutes ago
thirdshift: 03:00:07 #35 not settled: a sub-issue added or removed less than 30 minutes ago
thirdshift: 03:00:08 #36 not settled: a "blocked by" link added or removed less than 30 minutes ago
thirdshift: 03:00:08 no Ready issue on acme/widgets
```

Expand Down Expand Up @@ -839,7 +841,7 @@ PATH=/home/you/.local/bin:/home/you/.cargo/bin:/usr/local/bin:/usr/bin:/bin

The `PATH` line, the `cd` and `base main`, the log file's directory, the logins `claude` and `gh` need, and schedulers other than cron are as for an Architect run: see its [On a schedule](#on-a-schedule). What differs for a Pickup run:

- **The interval** sets how soon a Ready issue is taken and how much work is started: a pass takes one issue, so `*/30` starts at most one every half hour. A pass lasts as long as the run it dispatched, and the passes that fire meanwhile are skipped, so the schedule builds one issue of a repository at a time on a machine, and the first pass after it ends takes the next, unless the repository is by then at its [Claim limit](#the-claim-limit): each pull request left for review, and each failure left for you, holds a Claim, and at 3 of them, or the `pickup.limit` you set, the passes take nothing until you have dealt with one. An issue you have just labelled also waits ten minutes, until it is settled. A Run you start by hand on an Issue URL is outside all this: it neither waits for a pass nor makes one skip.
- **The interval** sets how soon a Ready issue is taken and how much work is started: a pass takes one issue, so `*/30` starts at most one every half hour. A pass lasts as long as the run it dispatched, and the passes that fire meanwhile are skipped, so the schedule builds one issue of a repository at a time on a machine, and the first pass after it ends takes the next, unless the repository is by then at its [Claim limit](#the-claim-limit): each pull request left for review, and each failure left for you, holds a Claim, and at 3 of them, or the `pickup.limit` you set, the passes take nothing until you have dealt with one. An issue you have just labelled also waits thirty minutes by default, or the `pickup.wait_minutes` you set, until it is settled. A Run you start by hand on an Issue URL is outside all this: it neither waits for a pass nor makes one skip.
- **One line per repository.** thirdshift keeps no list of repositories. A Pickup run and an Architect run on one repository each skip while the other is still running, the Spec run or Run it dispatched included, so both lines can go in one crontab and the two never build that repository at once. Passes on different repositories do run at the same time: give their lines different minutes, such as `15,45`, if they would compete for the machine.
- **A pass is skipped** when the [lock is held](#one-at-a-time), when the repository has no Ready issue, and when it is at its Claim limit. A skip exits `0`, so the scheduler sees no failure, and sends no email, even with Run notifications on. The repository's [Activity log](#logs) records a skip when its reason changes. Unless `activity.quiet_skips` is set, the log file holds each skip's line too, and before the line of a pass that found no Ready issue, as before the line of one that took an issue, [why each issue was passed over](#why-an-issue-was-passed-over). A pass skipped for the lock or the Claim limit looks at no issue, so it has no such lines.
- **A Run notification**, by `email.always = true` in the [User config](#user-config) or `email` on the line, is sent [only for an issue taken](#one-run-notification-for-the-issue-taken). So a day without email doesn't say the passes are running: a pass that fails before it takes an issue, on a broken User config, a missing Resend key or a failed check, shows up only in the log file.
Expand Down Expand Up @@ -867,7 +869,7 @@ The command's flags and the User config decide what a pass does with the issue i

An issue whose run failed [keeps its Claim](#when-the-claim-ends) and waits for the **Day shift**: it stays `in-progress`, with the Issue branch and any draft pull request the run left, no later pass takes it, and it counts towards the Claim limit until it is closed or you take the label off. To send it round again by hand, run `thirdshift <Issue URL>` from the clone, with the Base branch checked out, since that command takes no `base <branch>`: it picks up where the failed run stopped, as a [Continuation](#continuation) does. Labelling it `ready-for-agent` again doesn't do it: a Pickup run never takes an issue that was started. The one failure a later pass does retry is one that left nothing on `origin`, such as a usage limit or an expired login: its Claim is released, so the issue is a Ready issue again once it has settled.

If you shape your issues with the upstream `to-spec` and `to-tickets`, from [mattpocock/skills](https://github.com/mattpocock/skills), unchanged: `ready-for-agent` on a **Spec** must mean its **Tickets** are published, every Ticket attached as a sub-issue and every "blocked by" link in place. Upstream, `to-spec` labels the Spec `ready-for-agent` when it publishes it, minutes or days before `to-tickets` attaches the Tickets, and a Spec with no Tickets yet looks exactly like a standalone Ticket, so a pass would start a plain Run on it. Label such a Spec `needs-triage` until its Tickets are attached, then swap that for `ready-for-agent`, as the copies of the two this repository's own Day shift uses, under `.agents/skills/`, do ([`docs/agents/triage-labels.md`](docs/agents/triage-labels.md)). The ten minutes an issue must be settled for are only a second line of defence: they cover Tickets attached within minutes of the label, not a Spec left labelled and without Tickets for longer.
If you shape your issues with the upstream `to-spec` and `to-tickets`, from [mattpocock/skills](https://github.com/mattpocock/skills), unchanged: `ready-for-agent` on a **Spec** must mean its **Tickets** are published, every Ticket attached as a sub-issue and every "blocked by" link in place. Upstream, `to-spec` labels the Spec `ready-for-agent` when it publishes it, minutes or days before `to-tickets` attaches the Tickets, and a Spec with no Tickets yet looks exactly like a standalone Ticket, so a pass would start a plain Run on it. Label such a Spec `needs-triage` until its Tickets are attached, then swap that for `ready-for-agent`, as the copies of the two this repository's own Day shift uses, under `.agents/skills/`, do ([`docs/agents/triage-labels.md`](docs/agents/triage-labels.md)). The settling window (thirty minutes by default) is only a second line of defence: it covers Tickets attached within minutes of the label, not a Spec left labelled and without Tickets for longer.

## Building from source

Expand Down
1 change: 1 addition & 0 deletions src/asks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ mod tests {
email: EmailSettings::default(),
spec_parallel: n(3),
pickup_limit: n(3),
pickup_wait: chrono::TimeDelta::minutes(30),
harness: harness::Settings::default(),
}
}
Expand Down
20 changes: 20 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use std::num::NonZeroUsize;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result, anyhow, bail};
use chrono::TimeDelta;
use toml::{Table, Value};
use toml_edit::{DocumentMut, Item};

Expand Down Expand Up @@ -39,6 +40,10 @@ pub struct UserConfig {
/// `pickup.limit`: the Claim limit, how many open issues carrying a
/// Claim stop a Pickup run from taking another, by default 3.
pub pickup_limit: NonZeroUsize,
/// `pickup.wait_minutes`: how long after its latest shaping event an
/// issue becomes a Ready issue, by default 30 minutes. Zero disables
/// the settling window.
pub pickup_wait: TimeDelta,
/// The `[harness]` section: the Harness every Command runs its sessions
/// on, and a Model and Effort for each Harness.
pub harness: harness::Settings,
Expand Down Expand Up @@ -82,6 +87,7 @@ impl UserConfig {
email: EmailSettings::default(),
spec_parallel: NonZeroUsize::new(3).unwrap(),
pickup_limit: NonZeroUsize::new(3).unwrap(),
pickup_wait: TimeDelta::minutes(30),
harness: harness::Settings::default(),
}
}
Expand Down Expand Up @@ -151,6 +157,18 @@ impl UserConfig {
("pickup", "limit", value) => {
config.pickup_limit = whole_number_from_1(value, "pickup.limit", &file)?
}
("pickup", "wait_minutes", value) => {
config.pickup_wait = value
.as_integer()
.filter(|minutes| *minutes >= 0)
.and_then(TimeDelta::try_minutes)
.with_context(|| {
format!(
"pickup.wait_minutes must be a whole number of minutes from 0 up \
within the supported range in {file}"
)
})?;
}
("harness", "default", Value::String(name)) => match Harness::named(name) {
Some(harness) => config.harness.default = Some(harness),
None => bail!(
Expand Down Expand Up @@ -649,6 +667,7 @@ parallel = 3 # how many Tickets a Spec run runs at once; default 3

[pickup]
limit = 3 # how many open issues labelled in-progress stop a Pickup run taking another; default 3
wait_minutes = 30 # minutes since the latest shaping event before an issue is Ready; 0 disables waiting; default 30

[harness]
default = "claude" # the Harness every Run's sessions run on, claude, codex, agy, grok, muse or opencode; default claude
Expand Down Expand Up @@ -803,6 +822,7 @@ mod tests {
"activity.quiet_skips",
"spec.parallel",
"pickup.limit",
"pickup.wait_minutes",
"harness.default",
"harness.claude.model",
"harness.claude.effort",
Expand Down
7 changes: 6 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -247,10 +247,15 @@ ready-for-agent that has none of ready-for-human, needs-info, wontfix and needs-
not in-progress, is not a sub-issue, is not labelled base-fix, has no open blocker, and was
never started: no Issue branch for it is on origin, and no pull request from one exists,
open, merged or closed. A Spec whose Tickets are all closed is not one either: a Spec run
would find nothing to do. It must also be settled: ten minutes have passed since
would find nothing to do. It must also be settled: by default, thirty minutes have passed since
ready-for-agent was applied to it, and since a sub-issue or a \"blocked by\" link of its
was last added or removed, so a Spec is not taken while its Tickets are being attached. A
sub-issue is reached through its Spec, when the Spec is itself a Ready issue.
pickup.wait_minutes in the User config sets that settling window in whole minutes from
0 up, within the supported duration range; 0 disables waiting. There is no flag for it:

[pickup]
wait_minutes = 30

Each ready-for-agent issue a pass looks at and does not take gets one line on stderr with
the first reason that applies, such as #21 is a Ticket of #20, which is not ready or #30
Expand Down
2 changes: 1 addition & 1 deletion src/pass.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ impl Outside for LaunchAndGitHub<'_> {
}

fn ready_issue(&mut self) -> Result<Option<ReadyIssue>> {
ready::first(self.launch.git(), self.repo)
ready::first(self.launch.git(), self.repo, self.config.pickup_wait)
}

fn step(&mut self, line: String) {
Expand Down
Loading
Loading