The problem
Open pull requests rot: the base branch moves on, conflicts appear, and nobody notices until review. The fix is mostly mechanical: merge, resolve, push.
A dispatcher sandbox runs a shell loop you own. For each PR that is conflicted or behind, it creates a child instance on the PR’s branch, in its own worktree, merges the base in and starts only on a conflict. When it gives up, you get a notification.
Diagram: in the pr-dispatcher sandbox on your machine, babysit-loop.sh polls GitHub's open pull requests #123 and #124 with gh. For each, it asks devsandbox, through devsbd ensure and exec, served by the host daemon, to create a child instance: web-pr-123 and web-pr-124, each its own container and git worktree on the PR branch. A child merges the base in, runsClaude CodeCodex only on a conflict, and git pushes back to the PR. Failures go through devsbd notify to the dashboard's Inbox and your desktop.
Config
[template.base]
image = "node:22"
# One gh login for every sandbox: `gh auth login` once, in any of them.
mounts = ["source=${sharedVolumes}/gh,target=/root/.config/gh,type=bind"]
[template.base.features]
"ghcr.io/devcontainers/features/github-cli:1" = true
[sandbox.pr-dispatcher]
extends = "base"
folder = "../pr-dispatcher" # its own repo: babysit-loop.sh, .gitignore
autostart = "runtime" # docker/podman bring it back after a reboot
dispatcher = { spawn = ["web"] } # it may create children of `web` only
postStartCommand = "nohup ./babysit-loop.sh >babysit.log 2>&1 &"
[sandbox.web] # the children: web-pr-123, web-pr-124, …
extends = "base"
folder = "../web" # your repo
# Your login and settings, from the host.
mounts = ["source=${localEnv:HOME}/,target=/root/,type=bind"]
containerEnv = { = "/root/" }
[sandbox.web.postCreateCommand]
git-auth = "gh auth setup-git" # git fetch/push over https as your gh user
git-identity = "git config --global user.name 'PR babysitter' && git config --global user.email you@example.com"
agent = "npm install -g "[template.base]
image = "node:22"
# One gh login for every sandbox: `gh auth login` once, in any of them.
mounts = ["source=${sharedVolumes}/gh,target=/root/.config/gh,type=bind"]
[template.base.features]
"ghcr.io/devcontainers/features/github-cli:1" = true
[sandbox.pr-dispatcher]
extends = "base"
folder = "../pr-dispatcher" # its own repo: babysit-loop.sh, .gitignore
autostart = "runtime" # docker/podman bring it back after a reboot
dispatcher = { spawn = ["web"] } # it may create children of `web` only
postStartCommand = "nohup ./babysit-loop.sh >babysit.log 2>&1 &"
[sandbox.web] # the children: web-pr-123, web-pr-124, …
extends = "base"
folder = "../web" # your repo
# Your login and settings, from the host.
mounts = ["source=${localEnv:HOME}/,target=/root/,type=bind"]
[sandbox.web.postCreateCommand]
git-auth = "gh auth setup-git" # git fetch/push over https as your gh user
git-identity = "git config --global user.name 'PR babysitter' && git config --global user.email you@example.com"
agent = "npm install -g "On macOS keeps its login in the Keychain: run /login once in any instance.
The loop, in pr-dispatcher/ next to devsandboxes.toml, with a .gitignore for babysit.log and .babysit/. Set REPO and chmod +x it:
#!/bin/sh
set -u
REPO=owner/web
STATE=.babysit # per PR: "<run id> <head sha>"; gitignored
mkdir -p "$STATE"
# Runs inside the child, in the PR's worktree, with PR_NUMBER and BASE_BRANCH
# from `ensure --env`. Merging the base in (no rebase) keeps a plain
# `git push` enough; only starts when the merge conflicts.
FIX='
set -u
fail() {
git merge --abort 2>/dev/null
devsbd notify --level error --key "pr-$PR_NUMBER" -- "PR $PR_NUMBER: $1"
exit 1
}
git fetch origin "$BASE_BRANCH" || fail "git fetch failed"
if ! git merge --no-edit "origin/$BASE_BRANCH"; then
-p "Merging origin/$BASE_BRANCH into this PR branch stopped on
conflicts. Resolve every conflict, keeping the intent of both sides, and run
the tests if the project has any. Then git add the resolved files and
git commit --no-edit. Do not push. If a conflict needs a human decision,
stop and leave the merge unfinished." \
--permission-mode acceptEdits --allowedTools "Bash(git *),Bash(npm *)" ||
fail " exited with an error"
if [ -n "$(git diff --name-only --diff-filter=U)" ] ||
git rev-parse -q --verify MERGE_HEAD >/dev/null; then
fail "conflicts need a human"
fi
fi
git push || fail "git push failed"
'
# Run a control command, retrying while no host daemon is reachable (exit 75).
ctl() {
while :; do
"$@" </dev/null; rc=$?
[ "$rc" -eq 75 ] || return "$rc"
sleep 60
done
}
# One line per open same-repo PR: "<number> <head branch> <base branch> <head sha>".
OPEN='.[] | select(.isCrossRepository | not)
| "\(.number) \(.headRefName) \(.baseRefName) \(.headRefOid)"'
# Cheap check, no agent: conflicted or behind its base.
needs_attention() {
case $(gh pr view "$1" -R "$REPO" --json mergeStateStatus -q .mergeStateStatus </dev/null) in
DIRTY|BEHIND) return 0 ;;
*) return 1 ;;
esac
}
while :; do
# Same-repo PRs only ($OPEN): a fork's head branch isn't on origin. If gh
# fails (logged out, offline), skip the round rather than retire every child.
if ! gh pr list -R "$REPO" -q "$OPEN" \
--json number,headRefName,baseRefName,headRefOid,isCrossRepository \
>"$STATE/open.new"; then
sleep 300
continue
fi
mv "$STATE/open.new" "$STATE/open"
while read -r num branch base head; do
key="pr-$num"
if [ -f "$STATE/$key" ]; then
read -r id last <"$STATE/$key"
case $(ctl devsbd run wait "$key" "$id" --timeout 0) in
running) continue ;; # still on it
"exited 0") ;;
*) [ "$last" = "$head" ] && continue ;; # failed on this head: wait for a push
esac
fi
needs_attention "$num" || continue
ctl devsbd ensure web --key "$key" --branch "$branch" \
--env PR_NUMBER="$num" --env BASE_BRANCH="$base" >/dev/null || {
devsbd notify --level error --key "$key" "PR $num: cannot start a child (exit $?)"
continue
}
ctl devsbd run prune "$key" --keep 5 >/dev/null # old runs' logs
id=$(ctl devsbd exec "$key" --detach -- sh -c "$FIX") || continue
echo "$id $head" >"$STATE/$key"
done <"$STATE/open"
# Retire children whose PR is no longer open.
for f in "$STATE"/pr-*; do
[ -e "$f" ] || continue
key=${f##*/}
grep -q "^${key#pr-} " "$STATE/open" && continue
ctl devsbd rm "$key" && rm -f "$f"
done
sleep 300
done#!/bin/sh
set -u
REPO=owner/web
STATE=.babysit # per PR: "<run id> <head sha>"; gitignored
mkdir -p "$STATE"
# Runs inside the child, in the PR's worktree, with PR_NUMBER and BASE_BRANCH
# from `ensure --env`. Merging the base in (no rebase) keeps a plain
# `git push` enough; only starts when the merge conflicts.
FIX='
set -u
fail() {
git merge --abort 2>/dev/null
devsbd notify --level error --key "pr-$PR_NUMBER" -- "PR $PR_NUMBER: $1"
exit 1
}
git fetch origin "$BASE_BRANCH" || fail "git fetch failed"
if ! git merge --no-edit "origin/$BASE_BRANCH"; then
# No sandbox or approvals: the container is the sandbox.
exec --dangerously-bypass-approvals-and-sandbox "Merging
origin/$BASE_BRANCH into this PR branch stopped on conflicts. Resolve every
conflict, keeping the intent of both sides, and run the tests if the project
has any. Then git add the resolved files and git commit --no-edit. Do not push.
If a conflict needs a human decision, stop and leave the merge unfinished." ||
fail " exited with an error"
if [ -n "$(git diff --name-only --diff-filter=U)" ] ||
git rev-parse -q --verify MERGE_HEAD >/dev/null; then
fail "conflicts need a human"
fi
fi
git push || fail "git push failed"
'
# Run a control command, retrying while no host daemon is reachable (exit 75).
ctl() {
while :; do
"$@" </dev/null; rc=$?
[ "$rc" -eq 75 ] || return "$rc"
sleep 60
done
}
# One line per open same-repo PR: "<number> <head branch> <base branch> <head sha>".
OPEN='.[] | select(.isCrossRepository | not)
| "\(.number) \(.headRefName) \(.baseRefName) \(.headRefOid)"'
# Cheap check, no agent: conflicted or behind its base.
needs_attention() {
case $(gh pr view "$1" -R "$REPO" --json mergeStateStatus -q .mergeStateStatus </dev/null) in
DIRTY|BEHIND) return 0 ;;
*) return 1 ;;
esac
}
while :; do
# Same-repo PRs only ($OPEN): a fork's head branch isn't on origin. If gh
# fails (logged out, offline), skip the round rather than retire every child.
if ! gh pr list -R "$REPO" -q "$OPEN" \
--json number,headRefName,baseRefName,headRefOid,isCrossRepository \
>"$STATE/open.new"; then
sleep 300
continue
fi
mv "$STATE/open.new" "$STATE/open"
while read -r num branch base head; do
key="pr-$num"
if [ -f "$STATE/$key" ]; then
read -r id last <"$STATE/$key"
case $(ctl devsbd run wait "$key" "$id" --timeout 0) in
running) continue ;; # still on it
"exited 0") ;;
*) [ "$last" = "$head" ] && continue ;; # failed on this head: wait for a push
esac
fi
needs_attention "$num" || continue
ctl devsbd ensure web --key "$key" --branch "$branch" \
--env PR_NUMBER="$num" --env BASE_BRANCH="$base" >/dev/null || {
devsbd notify --level error --key "$key" "PR $num: cannot start a child (exit $?)"
continue
}
ctl devsbd run prune "$key" --keep 5 >/dev/null # old runs' logs
id=$(ctl devsbd exec "$key" --detach -- sh -c "$FIX") || continue
echo "$id $head" >"$STATE/$key"
done <"$STATE/open"
# Retire children whose PR is no longer open.
for f in "$STATE"/pr-*; do
[ -e "$f" ] || continue
key=${f##*/}
grep -q "^${key#pr-} " "$STATE/open" && continue
ctl devsbd rm "$key" && rm -f "$f"
done
sleep 300
doneIt is the sample from the automations guide, adapted: the child merges the base itself, a failure notifies you once per PR head, and fork PRs are skipped.
Try it
devsandbox run pr-dispatcher # starts the loop (idle until gh works)
devsandbox exec -it pr-dispatcher gh auth login # once, before the first child
devsandbox # the dashboard, to watch it workEvery five minutes the loop checks the PRs; a child web-pr-<number> appears in the Instances tab for each one that needs work. Failures land in the Inbox tab (4) and on your desktop. Follow along:
devsandbox exec pr-dispatcher tail -f babysit.log
devsandbox exec pr-dispatcher devsbd ls # the children
devsandbox exec -it web-pr-123 bash # look at a childHow it works
dispatcherlets the dispatcher create, stop and remove children of the sandboxes inspawn;devsbd ensure/exec/rmare those requests.- Control commands are served by the host daemon, which
runstarted and the running dispatcher keeps up, dashboard open or not. Exit75means no daemon is reachable (after a reboot, until your firstdevsandboxcommand;devsandbox serve installavoids that), whichctlretries; notifications wait for it. autostart = "runtime"restarts the dispatcher, and so the loop, after a reboot.- Each child runs unattended with your
ghlogin and pushes to your PR branches: keepspawnto what it needs and review what it pushes.
Known limitations
- Same-repo PRs only: a fork’s branch isn’t on
origin. - Children outlive the dispatcher; remove orphans with
devsandbox rm web-pr-123. ensureandrmrun git on the host, which refuses a repo whose.git/configsets command-running keys (core.sshCommand,credential.*, …).
Agent instructions
Set up a devsandbox PR babysitter for the GitHub repo <owner/repo> (checked out at <repo path>): a dispatcher spawns a child per open PR that is conflicted or behind, merges the base in, runs non-interactively on conflicts, pushes, and notifies me on failure.
- Read the devsandbox-cli and config-toml-spec skills first (load them if they are installed, otherwise read them from https://github.com/Nemikolh/devsandboxes/tree/main/skills). Follow them over your own assumptions: unknown config keys are hard errors.
- Find my devsandbox config dir: the folder holding devsandboxes.toml, passed to devsandbox as
-C <config dir>. If you cannot tell which one, ask me. - Read docs/automations-guide.md in the devsandboxes repo (https://github.com/Nemikolh/devsandboxes/blob/main/docs/automations-guide.md); use only the devsbd commands and flags it documents.
- In devsandboxes.toml, add a sandbox for the repo (
folder= <repo path>) with the github-cli Feature, a shared${sharedVolumes}/ghmount,gh auth setup-gitand a git identity of <your name> / <your email> inpostCreateCommand. Install there too and mount its host config dir (its login) into the container. - Add a
[sandbox.pr-dispatcher]with its ownfolder(a new git repo next to the config dir),autostart = "runtime",dispatcher = { spawn = ["<sandbox name>"] }(never"*") andpostStartCommand = "nohup ./babysit-loop.sh >babysit.log 2>&1 &". - Write babysit-loop.sh in that folder from https://devsandboxes.com/examples/babysit-pr (the variant), with
REPO=<owner/repo>and the sandbox name indevsbd ensure;chmod +xit, gitignorebabysit.logand.babysit/, check it withsh -n. - Validate with
devsandbox -C <config dir> lsand fix every error it reports before going on. - Ask me before starting anything. Then
devsandbox -C <config dir> run pr-dispatcherand tell me to rundevsandbox -C <config dir> exec -it pr-dispatcher gh auth loginmyself. - Then check
devsandbox -C <config dir> exec pr-dispatcher tail babysit.loganddevsandbox -C <config dir> exec pr-dispatcher devsbd lsafter the first round. Never push, merge or remove children yourself. - Ask me before anything destructive: devsandbox rm, gc, rebuild --force, service rebuild, deleting files or branches.
Source: site/src/content/examples/babysit-pr.mdxEdit this page on GitHub(opens in a new tab)