EXAMPLE 09
  • automations
  • agents
  • git

Babysit pull requests

An agent keeps your open PRs merged with their base, and pings you when it can't.


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.

Let your agent set this up

Config

devsandboxes.toml
[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 "
devsandboxes.toml
[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:

pr-dispatcher/babysit-loop.sh
#!/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
pr-dispatcher/babysit-loop.sh
#!/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
done

It 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

TERMINAL
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 work

Every 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:

TERMINAL
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 child

How it works

  • dispatcher lets the dispatcher create, stop and remove children of the sandboxes in spawn; devsbd ensure / exec / rm are those requests.
  • Control commands are served by the host daemon, which run started and the running dispatcher keeps up, dashboard open or not. Exit 75 means no daemon is reachable (after a reboot, until your first devsandbox command; devsandbox serve install avoids that), which ctl retries; notifications wait for it.
  • autostart = "runtime" restarts the dispatcher, and so the loop, after a reboot.
  • Each child runs unattended with your gh login and pushes to your PR branches: keep spawn to 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.
  • ensure and rm run git on the host, which refuses a repo whose .git/config sets command-running keys (core.sshCommand, credential.*, …).

Agent instructions

Prompt for your coding agentPaste into Claude Code, Codex or any agent with a shell.

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.

  1. 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.
  2. 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.
  3. 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.
  4. In devsandboxes.toml, add a sandbox for the repo (folder = <repo path>) with the github-cli Feature, a shared ${sharedVolumes}/gh mount, gh auth setup-git and a git identity of <your name> / <your email> in postCreateCommand. Install there too and mount its host config dir (its login) into the container.
  5. Add a [sandbox.pr-dispatcher] with its own folder (a new git repo next to the config dir), autostart = "runtime", dispatcher = { spawn = ["<sandbox name>"] } (never "*") and postStartCommand = "nohup ./babysit-loop.sh >babysit.log 2>&1 &".
  6. Write babysit-loop.sh in that folder from https://devsandboxes.com/examples/babysit-pr (the variant), with REPO=<owner/repo> and the sandbox name in devsbd ensure; chmod +x it, gitignore babysit.log and .babysit/, check it with sh -n.
  7. Validate with devsandbox -C <config dir> ls and fix every error it reports before going on.
  8. Ask me before starting anything. Then devsandbox -C <config dir> run pr-dispatcher and tell me to run devsandbox -C <config dir> exec -it pr-dispatcher gh auth login myself.
  9. Then check devsandbox -C <config dir> exec pr-dispatcher tail babysit.log and devsandbox -C <config dir> exec pr-dispatcher devsbd ls after the first round. Never push, merge or remove children yourself.
  10. Ask me before anything destructive: devsandbox rm, gc, rebuild --force, service rebuild, deleting files or branches.
fill in before sending Agent skills (opens in a new tab)

Source: site/src/content/examples/babysit-pr.mdxEdit this page on GitHub(opens in a new tab)

MENU /
View on GitHub
EXAMPLES
All examplesEvery recipe on one page.Parallel coding agentsSeveral coding agents on one repo, fully isolated from one another.One shared PostgresOne development database for all your sandboxes and their instances.Private services per instanceEvery instance gets its own Postgres and Redis, so agents never touch each other's data.Shared package cachesNew sandboxes stop downloading the same packages again.ssh-agent and git pushAgents push over ssh with your host's keys, which never enter the container.Port forwarding on demandOpen a sandbox's dev server or database from your host, without restarting anything.Attach VS CodeOpen any instance in VS Code with one key.devcontainer FeaturesAdd Node, Rust or the GitHub CLI to any image, in TOML.Babysit pull requestsAn agent keeps your open PRs merged with their base, and pings you when it can't.Port a devcontainer.jsonTurn an existing devcontainer.json into a devsandbox config.