• CI Needs a Return Address

    A man drinks coffee in the foreground while three robots gesture to one another in a sunlit workshop.

    I used to copy review comments out of GitLab and paste them into the agent’s chat. Open the MR, find the conversation, carry the text across. The agent already had the code and the task context. The feedback was waiting in another window.

    I had become the meat-based mailman between GitLab and the agent, and I did not want the job.

    CI left the same gap. An agent could finish its turn while the pipeline was still running. If it failed later, somebody had to notice and bring the result back. The next prompt hardly needed composing. It needed delivering.

    I built Paseo MR Watcher to take over the route. It is a plugin for Paseo, the app where I run coding agents in Git workspaces. It watches GitLab MRs for active workspaces and sends pipeline changes and review comments to the agent working there. The worker runs with the panel closed, so I do not have to watch the watcher either.

    Getting rid of the copy-paste also meant remembering which updates still needed attention. Otherwise I would still be checking whether the agent had picked up the message. Same job, slightly less typing.

    Which Agent Gets the Failure?

    A branch name is a poor address. Two repositories can both have a branch called fix-tests, and a local checkout can use a different name from the branch behind the MR. Matching one string against another is pleasantly short code. It also leaves rather a lot to coincidence.

    I keep the GitLab lookup separate from the local recipient lookup. The source repository and remote branch tell me where to look for the MR. To find its agent, I match the local repository, checkout branch, and worktree directory, excluding archived agents. A matching branch name in another directory is not enough.

    For example, a local branch called local-name can track an MR branch called remote-feature. The Git adapter preserves both: the watcher searches GitLab for remote-feature while selecting an agent on local-name. Renaming the local branch should not be the price of receiving a review comment.

    That still leaves the possibility of several agents in one worktree. A parent may have asked a child to review the change. Both are standing in the right directory; only one needs to receive the next CI failure.

    My first version made being a root agent part of eligibility. That excluded delegated executors, even when the delegate was the agent working on the MR. I removed that restriction, then added a narrower preference: among eligible agents in the same worktree, prefer an ancestor over its descendants. A parent working elsewhere does not get to collect the event merely by being a parent.

    A saved assignment wins before that preference runs, so I can deliberately select the child. If independent candidates remain, the plugin asks me to choose; if nobody qualifies, it offers agent creation. The selection code leaves that ambiguity with me. Directories and parent labels can narrow the choice, but they cannot pick between two unrelated conversations about the same checkout.

    Before sending, the worker refreshes the selected agent and checks its location and archive status again. This catches an agent that moved or was archived after discovery. It still cannot prove that the conversation remembers the right task. The notification tells the recipient to verify the current branch and MR head before acting.

    Accepted Is Still Pending

    Once I had an address, I could send a prompt. Deleting the event after a successful send would have made the rest of the implementation much shorter.

    It would also have confused two different receipts. Paseo accepting a message tells the watcher about transport. It says nothing about whether the agent has handled the CI failure or review comment. The agent might be interrupted before doing so. A watcher restart should not turn that interruption into forgotten work.

    The watcher saves events locally before sending them. It groups them into batches, records each delivery attempt, and keeps the batches pending after a successful send. That state survives a restart. Until the agent explicitly acknowledges handling the events, the watcher still has work to chase.

    If a send times out, the watcher retries at the next poll. If Paseo accepts the message but the agent never acknowledges handling it, the watcher sends a reminder. By default, it polls every three minutes and reminds after thirty; newly observed events do not wait for an older reminder timer. The worker tests check that an acknowledgment stops reminders for those events across delivery attempts.

    The notification includes a command for acknowledging its exact delivery. It asks the agent to run it after handling the events or reporting a blocker to the user. A failed pipeline can require a decision the agent is not authorized to make; repeating the notification forever will not supply the decision.

    An acknowledgment, or ACK, is the agent reporting that it dealt with the event. The watcher checks that it comes with the expected recipient’s identity and saves it. That proves nothing about the resulting patch. A green pipeline or a satisfactory review has to come from elsewhere.

    This follows an at-least-once delivery model. Pending events survive a restart. Delivery still needs an active MR in a tracked workspace, dispatch enabled, and a suitable recipient; transport has to become available for retries to succeed. A crash or uncertain send can produce a duplicate. There is no exactly-once guarantee for the agent’s actions, so a repeated notification must begin with checking what has already happened.

    The Old Recipient Can Still Answer

    Persisting deliveries made the routing correction more awkward. Changing who gets the next message does nothing about the messages already sent.

    The upgrade had to replay events sent to a different agent if the newly selected workspace agent had never received them. Some had already been acknowledged. Simply clearing that acknowledgment was not enough: the old agent could still answer its original notification later and clear the work waiting for the new recipient.

    So the routing migration gives the replayed batches new IDs and keeps the originals as history. A late ACK still names the old batches. It cannot clear the copies waiting for the workspace agent.

    This is specifically the migration from the old routing policy. Ordinary reassignment keeps batch identity, and an ACK from a recorded recipient can resolve those batches across delivery attempts. The new IDs let the upgrade demand attention in a new context without pretending that the old delivery never happened.

    Old Events, Current Work

    There is a separate problem with keeping events around: they get old. A failed pipeline captured at one SHA may be followed by a push and a successful pipeline at another. Every batch keeps its own SHA and time, even when delivered beside newer observations. The agent has to read that as history and check the current MR before repairing anything. A durable inbox can preserve an obsolete problem very reliably.

    That history is limited to what the watcher saw. It polls snapshots, includes the available notes when it first observes an open MR, then compares later observations for additions, edits, removals, and pipeline changes. A comment created and deleted between polls is invisible to it. So is an intermediate pipeline status that no poll captured. Retries cannot fill those gaps.

    A comment can also be perfectly current and ask for something the agent must not do. The notification format labels comments as untrusted data and tells the agent to continue only work already authorized by the user. A review comment asking for a fix can be relevant to that work. A comment asking the agent to merge or publish a review cannot supply the missing authorization. The harness’s tool and permission boundaries still have to enforce that rule; putting JSON between warning labels is no security sandbox.

    When the MR closes or merges, the watcher stops chasing its pending events. It makes one final notification attempt and does not retry it. I want the closed MR out of the reminder loop, even if that last message never arrives.

    Now I drink my coffee without checking pipeline pages by hand. The watcher does the rounds.