agents: add continuous Dependabot PR monitor to process-dependabot-prs (#4352)

Previously, `process-dependabot-prs` only evaluated open Dependabot PRs
when manually invoked, requiring manual re-runs whenever new PRs opened
or existing PRs were recreated after another merge.

Add `scripts/monitor_dependabot_prs.py` to poll GitHub in the background
once every 5 hours (or immediately on `SIGUSR1`) and wake the agent
conversation via `agentapi send-message` only when new or updated open
Dependabot PRs appear, when an open PR becomes conflicting, or when a
poll error occurs. Update
`.agents/skills/process-dependabot-prs/SKILL.md` to launch the
background daemon and trigger `SIGUSR1` after merging PRs.
diff --git a/.agents/skills/process-dependabot-prs/SKILL.md b/.agents/skills/process-dependabot-prs/SKILL.md
index 0c51cb5..1368211 100644
--- a/.agents/skills/process-dependabot-prs/SKILL.md
+++ b/.agents/skills/process-dependabot-prs/SKILL.md
@@ -1,28 +1,72 @@
 ---
 name: process-dependabot-prs
-description: Evaluate open Dependabot PRs against approval criteria, report
-  conclusions for user confirmation, fix trivial failures, and merge approved
-  PRs using the merge-pr skill
+description: Continuously monitor for open Dependabot PRs, evaluate them against
+  approval criteria, report conclusions for user confirmation, fix trivial
+  failures, and merge approved PRs using the merge-pr skill
 ---
 
 @.agents/rules/lockfiles.md
 
-When asked to process, review, or triage Dependabot pull requests:
+When asked to process, review, triage, or monitor Dependabot pull requests:
 
-## 1. Discover and Inspect Open Dependabot PRs
+## 1. Start Continuous Background Monitor
+
+1. Check Existing Monitor Process: Check if `monitor_dependabot_prs.py` is
+   already running using `pgrep -af "[m]onitor_dependabot_prs.py"` as a
+   standalone command (do NOT chain it with `|| ... monitor_dependabot_prs.py`
+   in a single shell invocation, as `pgrep -af` will match the parent `bash -c`
+   command string).
+2. Launch Monitor Daemon: If no monitor process is active, start it via
+   `run_command` with `IsDaemon: true` and `WaitMsBeforeAsync: 500` (do NOT
+   append a trailing `&`, which causes the shell to exit immediately and kills
+   the background process):
+   ```bash
+   ./.agents/skills/process-dependabot-prs/scripts/monitor_dependabot_prs.py \
+     "<your_conversation_id>"
+   ```
+3. Continuous Wakeup Loop:
+   - `monitor_dependabot_prs.py` polls GitHub once every 5 hours (`--interval`)
+     and tracks each open Dependabot PR's `(headRefOid, conflict_status)` in
+     `scratch/monitored_dependabot_prs.json` under the skill directory.
+   - When no new or updated open Dependabot PRs exist, the daemon sleeps without
+     waking the agent.
+   - Whenever a new Dependabot PR opens, an existing PR is rebased/recreated
+     with a new commit SHA (`headRefOid`), or an open PR becomes `CONFLICTING` /
+     `DIRTY` after another PR merges, the daemon sends a notification via
+     `agentapi send-message` to wake the conversation.
+   - Upon starting the daemon—and on every wakeup notification from the
+     daemon—execute Steps 2–5 below for any newly discovered or updated open
+     Dependabot PRs.
+4. Immediate Poll Trigger (`SIGUSR1`):
+   - Trigger an immediate poll cycle at any time (for example, right after a
+     `Merge PR Shepherd` finishes merging an approved Dependabot PR or after
+     refreshing credentials):
+     ```bash
+     pkill -USR1 -f "monitor_dependabot_prs.py"
+     ```
+5. Authentication Failures:
+   - When `gh` fails due to invalid or expired credentials, keep the background
+     monitor process running so it automatically resumes if credentials are
+     refreshed incidentally.
+   - Prompt the user via `ask_question` so a UI popup alerts them to
+     re-authenticate.
+   - After the user responds, trigger an immediate poll cycle via `pkill -USR1
+     -f "monitor_dependabot_prs.py"`.
+
+## 2. Discover and Inspect Open Dependabot PRs
 
 1. Query open Dependabot PRs:
    ```bash
    gh pr list --repo bazel-contrib/rules_python --author "app/dependabot" \
-     --state open --json number,title,url,headRefName,mergeable \
+     --state open --json number,title,url,headRefName,headRefOid,mergeable \
      --json mergeStateStatus,statusCheckRollup,files,labels,body,comments \
      --json reviews
    ```
-2. For each PR, inspect the diff (`gh pr diff <number> --repo
-   bazel-contrib/rules_python`) and check statuses (Buildkite, ReadTheDocs, and
-   GitHub Actions).
+2. For each new or updated open PR, inspect the diff (`gh pr diff <number>
+   --repo bazel-contrib/rules_python`) and check statuses (Buildkite,
+   ReadTheDocs, and GitHub Actions).
 
-## 2. Evaluate Against Criteria (OK vs. Problematic)
+## 3. Evaluate Against Criteria (OK vs. Problematic)
 
 Classify each PR as **OK**, **OK (with trivial fixes)**, or **Problematic**:
 
@@ -36,7 +80,7 @@
   leaf tooling dependencies where release notes show no breaking changes to
   repository usage and CI passes.
 * **Trivial Synchronization Failures Only**: CI fails solely due to stale
-  derived files or hardcoded version assertions covered in Step 4.
+  derived files or hardcoded version assertions covered in Step 5.
 
 ### Problematic (Flag for User Attention)
 * **Duplicate or Superseded PRs**: Overlapping `pip` and `uv` group PRs
@@ -55,7 +99,7 @@
 * **Suspicious Releases**: Upstream repository URL changes, yanked versions, or
   release notes indicating known regressions.
 
-## 3. Notify User and Request Confirmation
+## 4. Notify User and Request Confirmation
 
 1. **Create Per-PR Analysis Artifacts**: For each evaluated PR, create a
    markdown artifact `dependabot-pr-<number>.md` in the conversation artifact
@@ -78,7 +122,7 @@
      - `Manually review`
 3. Wait for user confirmation before mutating any PR.
 
-## 4. Fix Trivial Failures and Merge Approved PRs
+## 5. Fix Trivial Failures and Merge Approved PRs
 
 Once the user confirms:
 
@@ -131,3 +175,7 @@
      <pr_number> --repo bazel-contrib/rules_python --auto --squash`) and launch
      a `Merge PR Shepherd` subagent to monitor CI, retry flakes, and track the
      merge queue to completion.
+   - When a `Merge PR Shepherd` reports that a Dependabot PR has merged, trigger
+     an immediate poll (`pkill -USR1 -f "monitor_dependabot_prs.py"`) so any
+     remaining open Dependabot PRs that became conflicting are detected and
+     recreated right away.
diff --git a/.agents/skills/process-dependabot-prs/scripts/monitor_dependabot_prs.py b/.agents/skills/process-dependabot-prs/scripts/monitor_dependabot_prs.py
new file mode 100755
index 0000000..2207bbb
--- /dev/null
+++ b/.agents/skills/process-dependabot-prs/scripts/monitor_dependabot_prs.py
@@ -0,0 +1,294 @@
+#!/usr/bin/env python3
+"""Continuously polls GitHub for new or updated open Dependabot pull requests.
+
+State file format:
+  A JSON object mapping open PR number strings (e.g. `"4301"`) to their last
+  notified state token (`"<headRefOid>:<CLEAN|CONFLICTING>"`).
+
+Usage:
+  ./.agents/skills/process-dependabot-prs/scripts/monitor_dependabot_prs.py \\
+      "<conversation_id>"
+
+Triggering an immediate poll cycle:
+  kill -USR1 <PID>
+"""
+
+from __future__ import annotations
+
+import argparse
+import fcntl
+import json
+import os
+import pathlib
+import signal
+import subprocess
+import sys
+import threading
+import traceback
+from collections.abc import Mapping, Sequence
+from datetime import datetime
+
+_POLL_TRIGGER = threading.Event()
+_REPO = "bazel-contrib/rules_python"
+_DEPENDABOT_AUTHOR = "app/dependabot"
+# Poll once every 5 hours (18,000 seconds) by default; immediate checks after a
+# PR merges can be triggered via SIGUSR1.
+_DEFAULT_INTERVAL_SECONDS = 5 * 60 * 60
+_SKILL_DIR = pathlib.Path(__file__).resolve().parent.parent
+_SCRATCH_DIR = _SKILL_DIR / "scratch"
+_STATE_FILE = _SCRATCH_DIR / "monitored_dependabot_prs.json"
+_LOCK_FILE = _SCRATCH_DIR / ".monitor_dependabot_prs.lock"
+
+
+def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
+    """Parses command-line arguments."""
+    parser = argparse.ArgumentParser(
+        description=(
+            "Continuously monitor open Dependabot PRs and notify an agent "
+            "conversation when new or updated PRs appear."
+        ),
+        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+    )
+    parser.add_argument(
+        "conv_id",
+        help="Conversation ID to notify via agentapi when PRs need processing.",
+    )
+    parser.add_argument(
+        "--interval",
+        type=int,
+        default=_DEFAULT_INTERVAL_SECONDS,
+        help="Polling interval in seconds.",
+    )
+    return parser.parse_args(argv)
+
+
+def handle_wakeup_signal(signum: int, unused_frame: object) -> None:
+    """Triggers an immediate polling iteration upon receiving SIGUSR1."""
+    sig_name = signal.Signals(signum).name
+    print(
+        f"\n[Signal] Received {sig_name} ({signum}), triggering immediate poll...",
+        flush=True,
+    )
+    _POLL_TRIGGER.set()
+
+
+def register_signal_handlers() -> None:
+    """Registers SIGUSR1 for asynchronous poll wakeups."""
+    signal.signal(signal.SIGUSR1, handle_wakeup_signal)
+
+
+def format_subprocess_output(stdout: str, stderr: str) -> str:
+    """Formats captured subprocess stdout and stderr with standard delimiters."""
+    return (
+        "==================== STDOUT BEGIN ====================\n"
+        f"{stdout}\n"
+        "==================== STDOUT END ====================\n"
+        "==================== STDERR BEGIN ====================\n"
+        f"{stderr}\n"
+        "==================== STDERR END ===================="
+    )
+
+
+def fetch_open_dependabot_prs() -> list[dict[str, object]]:
+    """Queries GitHub for currently open Dependabot pull requests."""
+    cmd = [
+        "gh",
+        "pr",
+        "list",
+        "--repo",
+        _REPO,
+        "--author",
+        _DEPENDABOT_AUTHOR,
+        "--state",
+        "open",
+        "--json",
+        "number,title,url,headRefName,headRefOid,mergeable,mergeStateStatus",
+    ]
+    try:
+        res = subprocess.run(cmd, capture_output=True, text=True, check=True)
+    except subprocess.CalledProcessError as e:
+        e.add_note(format_subprocess_output(e.stdout or "", e.stderr or ""))
+        raise
+    return json.loads(res.stdout)
+
+
+def load_seen_state() -> dict[str, str]:
+    """Loads the mapping of PR number strings to state tokens from disk."""
+    if not _STATE_FILE.exists():
+        return {}
+    with _STATE_FILE.open(encoding="utf-8") as f:
+        data = json.load(f)
+    return {str(k): str(v) for k, v in data.items()}
+
+
+def save_seen_state(seen: Mapping[str, str]) -> None:
+    """Writes the mapping of PR number strings to state tokens to disk."""
+    _STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
+    with _STATE_FILE.open("w", encoding="utf-8") as f:
+        json.dump(dict(sorted(seen.items())), f, indent=2)
+        f.write("\n")
+
+
+def compute_pr_state_token(pr: Mapping[str, object]) -> str:
+    """Computes a state token capturing the PR's head SHA and conflict status."""
+    head_oid = str(pr["headRefOid"])
+    is_conflicting = (
+        pr.get("mergeable") == "CONFLICTING" or pr.get("mergeStateStatus") == "DIRTY"
+    )
+    conflict_status = "CONFLICTING" if is_conflicting else "CLEAN"
+    return f"{head_oid}:{conflict_status}"
+
+
+def select_actionable_prs(
+    open_prs: Sequence[Mapping[str, object]],
+    seen: Mapping[str, str],
+) -> tuple[list[Mapping[str, object]], dict[str, str]]:
+    """Identifies new or updated PRs and returns the updated state dictionary."""
+    actionable: list[Mapping[str, object]] = []
+    updated_seen: dict[str, str] = {}
+    for pr in open_prs:
+        pr_num = str(pr["number"])
+        token = compute_pr_state_token(pr)
+        updated_seen[pr_num] = token
+        if seen.get(pr_num) != token:
+            actionable.append(pr)
+    return actionable, updated_seen
+
+
+def notify_conversation(conv_id: str, title: str, message: str) -> None:
+    """Sends a high-priority notification message to the target conversation."""
+    cmd = ["agentapi", "send-message", f"--title={title}", conv_id, message]
+    try:
+        subprocess.run(cmd, capture_output=True, text=True, check=True)
+    except subprocess.CalledProcessError as e:
+        e.add_note(format_subprocess_output(e.stdout or "", e.stderr or ""))
+        raise
+
+
+def format_notification_message(prs: Sequence[Mapping[str, object]]) -> str:
+    """Builds the notification message listing new or updated Dependabot PRs."""
+    lines = [
+        f"🔔 Detected {len(prs)} new or updated open Dependabot PR(s):",
+        "",
+    ]
+    for pr in prs:
+        number = pr["number"]
+        title = pr["title"]
+        url = pr["url"]
+        head_oid = str(pr["headRefOid"])[:8]
+        merge_state = pr.get("mergeStateStatus", "UNKNOWN")
+        lines.append(
+            f"- #{number}: {title} ({url}) [SHA: `{head_oid}`, mergeStateStatus: `{merge_state}`]"
+        )
+    lines.extend(
+        [
+            "",
+            "Action required: Run the `process-dependabot-prs` workflow on the "
+            "above PR(s).",
+        ]
+    )
+    return "\n".join(lines)
+
+
+def poll_once(conv_id: str) -> int:
+    """Executes one polling cycle and returns the count of newly notified PRs."""
+    timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+    open_prs = fetch_open_dependabot_prs()
+    seen = load_seen_state()
+    actionable, updated_seen = select_actionable_prs(open_prs, seen)
+
+    if not actionable:
+        save_seen_state(updated_seen)
+        print(
+            f"[{timestamp}] {len(open_prs)} open Dependabot PR(s); 0 new or updated.",
+            flush=True,
+        )
+        return 0
+
+    msg = format_notification_message(actionable)
+    notify_conversation(conv_id, "Dependabot PRs Ready", msg)
+    save_seen_state(updated_seen)
+    print(
+        f"[{timestamp}] Notified conversation {conv_id} about {len(actionable)} "
+        "Dependabot PR(s).",
+        flush=True,
+    )
+    return len(actionable)
+
+
+def main(argv: Sequence[str] | None = None) -> None:
+    """Main entrypoint for the continuous Dependabot PR monitor daemon."""
+    args = parse_args(argv)
+    _SCRATCH_DIR.mkdir(parents=True, exist_ok=True)
+
+    with _LOCK_FILE.open("a+", encoding="utf-8") as lock_file:
+        try:
+            fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
+        except OSError:
+            print(
+                "â„šī¸ Continuous Dependabot PR monitor is already running in "
+                "another process. Exiting.",
+                flush=True,
+            )
+            sys.exit(0)
+
+        lock_file.seek(0)
+        lock_file.truncate(0)
+        lock_file.write(
+            json.dumps({"pid": os.getpid(), "notify_conv_id": args.conv_id}) + "\n"
+        )
+        lock_file.flush()
+
+        register_signal_handlers()
+        pid = os.getpid()
+        print(
+            f"🚀 Starting continuous Dependabot PR monitor (PID: {pid}) for "
+            f"conversation {args.conv_id} every {args.interval}s...",
+            flush=True,
+        )
+        print(f"To trigger an immediate poll: kill -USR1 {pid}", flush=True)
+
+        error_notified = False
+        while True:
+            woke_by_signal = _POLL_TRIGGER.is_set()
+            _POLL_TRIGGER.clear()
+            if woke_by_signal:
+                error_notified = False
+            try:
+                poll_once(args.conv_id)
+                error_notified = False
+            except (
+                subprocess.CalledProcessError,
+                OSError,
+                json.JSONDecodeError,
+            ) as e:
+                timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+                details = "".join(traceback.format_exception_only(e)).strip()
+                print(
+                    f"[{timestamp}] Warning: Dependabot poll failed: {details}",
+                    file=sys.stderr,
+                    flush=True,
+                )
+                if not error_notified:
+                    try:
+                        notify_conversation(
+                            args.conv_id,
+                            "Dependabot Monitor Error",
+                            "âš ī¸ Dependabot PR monitor poll failed. If this is an "
+                            "authentication error, prompt the user via "
+                            "`ask_question` to re-authenticate:\n\n"
+                            f"```\n{details}\n```",
+                        )
+                        error_notified = True
+                    except (subprocess.CalledProcessError, OSError) as notify_err:
+                        print(
+                            f"[{timestamp}] Warning: Failed to notify "
+                            f"conversation: {notify_err}",
+                            file=sys.stderr,
+                            flush=True,
+                        )
+            _POLL_TRIGGER.wait(timeout=args.interval)
+
+
+if __name__ == "__main__":
+    main()