keep PRs moving overnight a night watch that merges what is ready
Agents open PRs all day; at night nobody merges them. A night watch keeps them moving while you sleep. Every five minutes a script merges what is ready and sends fix agents to what is broken. A model wakes only for what the script cannot decide, so most rounds cost nothing.
what you need #
ghlogged in on the head, with the right to merge onowner/app, andjq.- A model named
sonnetunder[models]inpastor.toml(a PR fix round shows the entry). - A
local = truemachine inflock.toml. The orchestrator’s own agent runs only on the head’s machine, and takes one of its slots. ~/src/appcloned on the head and on the machines that take fix tasks.
the orchestrator #
# ~/.config/pastor/orchestrators/night.toml
kind = "scheduled"
description = "Merges the night's ready PRs on the app"
cron = "*/5 22-23,0-7 * * *"
pre = ["./night-pre.sh"]
model = "sonnet"
repo = "~/src/app"
prompt = """
Each line below is a PR or a task the night watch could not handle alone.
Answer a blocked task with pastor task send, reply to or resolve review
threads, or start a fix with pastor task run. Leave to a person what needs
one, and say so in the handover note.
"""cron is in the head’s local time: every five minutes from 22:00 to 07:59.
pre is relative to the file. The script runs on the head with the
orchestrator’s rights: it may run, retry, send to and close tasks, and
nothing else that changes a flock. It gets a scratch directory of its own
in PASTOR_ORCHESTRATOR_STATE_DIR, kept between rounds.
the pre script, night-pre.sh
#!/bin/sh
# ~/.config/pastor/orchestrators/night-pre.sh
# One round of the night watch: do the mechanical work, print the rest.
set -eu
repo=owner/app
dir=$PASTOR_ORCHESTRATOR_STATE_DIR
# The state of the newest task with this description, empty if none.
task_state() {
pastor task list --json |
jq -r --arg d "$1" '[.[] | select(.description == $d)] | max_by(.id) | .state // empty'
}
live() { case "$1" in queued|starting|running|blocked|paused|waiting) return 0 ;; esac; return 1; }
# Open review threads on a PR, over every page of them.
unresolved() {
gh api graphql --paginate -F owner="${repo%/*}" -F name="${repo#*/}" -F pr="$1" -f query='
query($owner: String!, $name: String!, $pr: Int!, $endCursor: String) {
repository(owner: $owner, name: $name) { pullRequest(number: $pr) {
reviewThreads(first: 100, after: $endCursor) {
nodes { isResolved } pageInfo { hasNextPage endCursor } } } } }' \
--jq '[.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved | not)] | length' |
awk '{ n += $1 } END { print n + 0 }'
}
# One line per open PR: number, head sha, merge state, review, checks.
# No checks reported yet counts as PENDING, never GREEN.
gh pr list --repo "$repo" --search draft:false --limit 200 \
--json number,headRefOid,mergeStateStatus,reviewDecision,statusCheckRollup \
--jq '.[] | "\(.number) \(.headRefOid[0:7]) \(.mergeStateStatus) \(.reviewDecision // "" | if . == "" then "NONE" else . end) \(
[.statusCheckRollup[] | (.conclusion // "") as $c | if $c != "" then $c else (.state // "PENDING") end]
| if length == 0 then "PENDING"
elif any(. == "FAILURE" or . == "ERROR" or . == "TIMED_OUT" or . == "CANCELLED") then "FAILING"
elif all(. == "SUCCESS" or . == "NEUTRAL" or . == "SKIPPED") then "GREEN"
else "PENDING" end)"' >"$dir/prs"
# The PR whose rebase is in flight; it holds the others until it merges.
rebasing=$(cat "$dir/rebasing" 2>/dev/null || true)
if [ -n "$rebasing" ] && ! grep -q "^$rebasing " "$dir/prs"; then
rm -f "$dir/rebasing"
rebasing=
fi
merged=no
while read -r pr sha merge review checks <&3; do
if [ "$merge" = DIRTY ] || [ "$checks" = FAILING ]; then
[ "$pr" = "$rebasing" ] && rm -f "$dir/rebasing" && rebasing=
what="fix PR #$pr at $sha"
if [ ! -e "$dir/sent-$pr-$sha" ]; then
pastor task run "Check out PR #$pr with gh pr checkout $pr, rebase it on main, fix what conflicts or fails, run the tests and push." \
--repo '~/src/app' --worktree --model sonnet --description "$what" >&2
touch "$dir/sent-$pr-$sha"
elif ! live "$(task_state "$what")"; then
echo "PR #$pr: $what ended with nothing pushed"
fi
elif [ "$review" = CHANGES_REQUESTED ]; then
echo "PR #$pr: changes requested"
elif [ "$review" != APPROVED ] || [ "$checks" != GREEN ]; then
: # waiting on a reviewer or on CI
elif [ "$(unresolved "$pr")" != 0 ]; then
echo "PR #$pr: approved, with unresolved review threads"
elif [ "$merge" = CLEAN ] && [ "$merged" = no ]; then
# One merge a round: the next one's state is stale until GitHub catches up.
if gh pr merge "$pr" --repo "$repo" --merge --delete-branch >&2; then
merged=yes
else
echo "PR #$pr: approved and green, but the merge was refused"
fi
elif [ "$merge" = BEHIND ] && [ -z "$rebasing" ]; then
# A rebase that conflicts is refused; the PR shows DIRTY next round
# and gets a fix agent then.
if gh pr update-branch "$pr" --repo "$repo" --rebase >&2; then
echo "$pr" >"$dir/rebasing"
rebasing=$pr
fi
elif [ "$merge" = BLOCKED ]; then
echo "PR #$pr: approved and green, but GitHub still blocks the merge"
fi
done 3<"$dir/prs"
# A blocked task needs an answer.
pastor task list --json |
jq -r '.[] | select(.state == "blocked") | "TASK t-\(.id) blocked: \(.description)"'Make it executable with chmod +x. Everything the script prints on stdout
is a line for the agent; what it does itself goes to stderr.
one round #
- If the last round’s agent still works, the round is skipped.
- The script reads the open PRs once.
- A PR that conflicts with main or fails its checks gets one fix agent per commit, in its own worktree. If that agent ends without pushing, the next round prints a line about it.
- The first approved, green PR with no open threads is merged. One merge a round, because the next PR’s state is stale until GitHub catches up.
- One PR behind main is rebased. The others wait until it merges or needs a fix, so a merge never leaves the next one stale while it waits for CI.
- What is left is printed: approved PRs with open threads, changes requested, a merge GitHub refused, a fix that ended with nothing pushed, a blocked task.
- No lines, no agent. With lines, pastor starts one agent on the head’s machine with the orchestrator role, the handover note and every line in its prompt.
what you see #
Try one round by hand before the night. orchestrator run ignores the
schedule:
$ pastor orchestrator run night
$ pastor orchestrator listNAME KIND STATE SCHEDULE LAST RUN NEXT RUN AGENT LAST RESULT
night scheduled running cron */5 22-23,0-7 * * * 1m ago in 4m t-31 started t-31A quiet round reads no lines under LAST RESULT. The agent and the fix
agents are ordinary tasks. While an orchestrator agent is live,
pastor task list shows it in a table of its own:
orchestrators:
ID STATE PRIORITY MACHINE FLOCK AGENT MODEL JOB AGE NOTE
t-31 running normal desk default claude sonnet run 1m Each line below is a PR or a task the night watch could not
tasks:
ID STATE PRIORITY MACHINE FLOCK AGENT MODEL JOB AGE NOTE
t-30 running normal server-1 default claude sonnet run 4m Check out PR #44 with gh pr checkout 44, rebase it on main,In the morning, pastor orchestrator describe night shows the last rounds
with the lines each printed, the handover note and recent events.
next #
- orchestrators: scheduled and session kinds, the role
- let your agent drive pastor: what the orchestrator’s agent may do
- cli: orchestrator