Skip to content

feat: loop watches, routines that start a loop, and automation in the web UI - #427

Merged
saucam merged 13 commits into
mainfrom
feat/loop-watches
Oct 25, 2026
Merged

saucam merged 13 commits into
mainfrom
feat/loop-watches

Conversation

@saucam

@saucam saucam commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator

Loops core (#426) is merged; this PR is rebased onto main, so its diff is only the watches, routines that start a loop, and the web UI for both.

What

Watches. A watch starts a repair loop by itself when a CI check you mapped fails on a watched branch, or when someone you allow comments @codeoid on one of your open pull requests.

codeoid loop watch add --workdir ~/src/shop --my-prs --ci "test: bun test" --review
codeoid loop watch add --workdir ~/src/shop --branch main --ci "ci@github-actions: bun test" --trust alice --trust-head main=<full sha>

Routines can start a loop. Instead of prompting an agent, a routine's run can be a loop run: the routine's prompt is the objective, and the loop's own checks verify the result.
The --check gate still decides whether there is work, at no cost.

codeoid routine add nightly-flaky --cron "0 3 * * *" --check "./scripts/find-flaky.sh" \
  --loop --verify "tests: bun test" --attempts 3

Web UI. The loops drawer is now an Automation drawer with three tabs: Loop runs, Watches and Routines.
You can create, edit, run, pause and delete routines (including loop routines), and create watches and manage their CI mappings, trust list and vouching.
The "Needs you" inbox also lists failing watches, routines that paused themselves or failed, and routines waiting on their loop.

User guides: the Watches section of docs/loops.md, and "Routines that start a loop" in docs/routines.md.

How a watch decides whose code it may run

A run executes the code at a head unattended, so a watch acts only on heads vouched for by someone you trust, as GitHub authenticates it:

  • Pushes. A head pushed by you, or by a login you named with --trust, is trusted, according to GitHub's repository activity (a push vouches for everything beneath it).
  • Merges. A head a merge put on a branch is trusted only when every change to the branch since a trusted head was a trusted act: a trusted push, or a merge where each pull request it brought in (stacks and merge-queue batches included) was button-merged by a trusted login, or auto-merged / queued by one with a trusted final head.
  • Signatures. Otherwise, every commit past the base must be signed and verified by a trusted committer (web-flow vouches for no one).
  • Vouching. --trust-head [branch=sha] lets you vouch for a merge-only branch's current head. A pin must be a full commit id, and it is a compare-and-set, audited. The web UI only vouches pinned, never pre-fills a commit nobody looked at, and after a moved head asks you to confirm you looked at the new one.
  • Comments. Comments must be unquoted, unedited, from allowed authors, and published after the watch began; GitHub text reaches the worker only as data.

Loop routines

  • Authorization. Saving, changing, firing, re-tokening or resuming a loop routine needs session:create, session:send and session:approve. Delegated tokens are refused; pausing stays allowed as a safety stop.
  • Data. The gate's output and the API payload reach the worker as two separate data blocks, never as instructions. A loop routine refuses {{…}} placeholders.
  • Bounds. One loop per routine at a time. The owner's active-loop cap skips rather than fails. A blocked or failed loop counts toward the routine's failure pause, and the loop's cost (including an interrupted attempt's) counts toward --daily-cost.
  • Lifecycle. Cancelling or deleting the routine cancels its loop, and a fire during a delete is refused. After a crash, a run is relinked to its own in-progress loop (same owner, same routine).
  • Known gap, documented. As with all routines and watches, grants are checked when a routine is saved, not when it fires.

Hardening (watches)

  • Fetching. The PR head is fetched over https by a throwaway repository the daemon owns, with gh as the only credential, and the pack is added offline. Shallow and partial clones are refused.
  • Bounds. API cost, watches per owner and PR targets are capped, and a rate-limit backoff is shared per GitHub login.
  • Access. A delegated token can pause or delete a watch, but can't create one, widen its trust, or vouch.

Review

Watches went through 10 audit rounds by independent security and correctness reviewers, several against live GitHub data (cli/cli, astral-sh/uv, neovim, bevy).
Loop routines and the web UI had 2 more deep rounds (security + correctness each); round 2's findings are fixed in the last commit, every one with a regression test checked to fail on the old code.
Full suite: daemon 4389 pass / 0 fail, web 880 pass.

🤖 Generated with Claude Code

@saucam
saucam changed the base branch from feat/loops to main October 25, 2026 18:34
saucam and others added 12 commits October 26, 2026 02:55
`codeoid loop watch …` polls GitHub through the owner's `gh` login and
starts loop runs on its own: when mapped CI checks fail on a watched
branch (or one of the owner's pull requests), or when an allowed author
comments @Codeoid on the owner's PRs. Runs repair the branch the event
happened on, from a clean checkout of its head commit.

- Trust rule: a head runs only when every commit past the default branch
  was written by someone the owner trusts; quoted lines, code and HTML
  comments are stripped before a mention counts; each event acts once
  across all of an owner's watches; one repair per branch at a time;
  per-watch hourly cap; rate-limit backoff; a run whose workspace couldn't
  be prepared gives its event back, three tries in all.
- Contract gains baseRef (commit, branch, repo, fetch ref, trusted) and
  trigger (kind, key, url, summary). The event text reaches the worker
  only as a quoted data block, in the first prompt and a takeover's.
- A review comment's run doesn't stop "blocked" when its checks already
  pass (they guard the change), and says so when the worker changed nothing.
- Setup recipe copies (an .env) only land next to a commit off the owner's
  history when the watch vouched for it (copyNeedsReviewedBase); a setup
  command that didn't run is a workspace.note in the run's history.
- A head that isn't local is fetched from GitHub by URL into a repository
  the daemon makes for the fetch, borrowing the owner's objects, so the
  repository's agent-writable config (url rewrites, proxies, TLS settings,
  credential helpers) has no say over it; https only, gh as the only
  credential helper. It is the only daemon git with the network on
  (hardenedGit's new `network` option names the one protocol allowed);
  the fetched pack is indexed into the owner's repository offline by
  hardened git. createForkWorktree's baseCommit resolves with hardened git.
- loop.watch.{create,list,get,update,delete,poll} authorized like loop.*
  (owner-only; create/change/enable/poll need session:approve and refuse
  delegated tokens; pause and delete are safety stops). CLI, terminal
  formatting, web loop detail, settings (loops.watchPollSeconds), docs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Trust: a head is trusted when GitHub's repository activity records a
  trusted login pushing it onto its branch (the fork's, for a fork PR), or
  when every commit past the default branch is signed, verified, and
  committed by a trusted login (web-flow only when verified and the author is
  trusted). Commit logins from email matching no longer vouch for anything.
- Comments edited more than 5s after they were made are ignored for good;
  review bodies are read over GraphQL for lastEditedAt.
- A skipped review comment is kept as a retry row so `since` stays pinned;
  retry rows on a closed PR or older than a week expire.
- Shallow and partial clones are refused before fetching, and not retried.
- Fetch hardening: alternateRefsCommand=true, no lazy fetch, no redirects,
  fsckObjects, no maintenance/gc, index-pack --strict streamed from the
  file, 2 GiB pack cap, remote-tracking tips as haves.
- commitsBetween needs every commit; only real rate limits back off, shared
  per gh login; a changed gh login pauses the watch.
- Branch mode ignores fork PRs; a watch changed mid-poll starts nothing; a
  failed bind releases its claim; CI keys use check run + suite ids.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Trust: a push or a pr_merge / merge_queue_merge recorded as a trusted
login's vouches for the head; web-flow commits never count on the
signature path (and web-flow can't be named in --trust); an empty compare
range is untrusted; a head GitHub's activity doesn't list yet is judged
again for ten minutes before it is recorded untrusted.

Comments: line and conversation comments are read through GraphQL by the
node ids REST lists, with publishedAt as when they were made and any
lastEditedAt as an edit (no grace window); pending review content is
excluded, so a batched review's drafted comments fire once it is
submitted. Review bodies use publishedAt and exclude pending reviews.

Minor: the rate-limit back-off starts over after any poll that wasn't
rate-limited; an edited comment waiting to retry becomes ignored; the
fetch's haves come only from remotes that are github.com/<repo> and that
no insteadOf rewrites; the 2 GiB pack bound is documented as checked after
the download (git has no client-side cap), bounded meanwhile by the
timeout. docs/loops.md states what trusting by pusher implies.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- A merge at the head is judged through the pull request GitHub merged as
  that commit into the watched branch (commits/{sha}/pulls, merge_commit_sha
  and base checked, then GraphQL for merger and auto-merge). A button merge
  by a trusted login vouches as before. An auto-merged pull request (or one
  GitHub can't say about) and any merge queue merge vouch only when a
  trusted login turned auto-merge on / queued it and the pull request's
  final head is itself trusted by push or signatures, with no further merge
  recursion.
- --trust takes bot logins (name[bot]); github-merge-queue[bot] and web-flow
  stay refused. --allow still takes people only.
- GraphQL comment reads use the answer when every error is NOT_FOUND (a
  comment deleted after REST listed it), skipping those nodes.
- Comment nodes carry repository { nameWithOwner } and must be the watched
  repository's.
- The fake gh models GitHub's merge, merge queue, auto-merge and NOT_FOUND
  behavior; the merge queue test uses github-merge-queue[bot] as actor.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- A merge vouches for the head only when what it landed on (the activity's
  `before`) is itself vouched for, one level down: nothing (a branch's first
  push), a head this watch already judged trusted (now persisted per watch,
  repository and branch, and forgotten when the trust list changes), or a
  trusted push or plain button merge just beneath it. Otherwise the head
  falls to the signature rule.
- Stacked pull requests: the merged pull request is no longer matched to the
  watched branch by its base, since GitHub merges a stack onto the parent's
  branch and fast-forwards the default branch (astral-sh/uv e96aa69, #21458).
- github-actions[bot] is refused in --trust.
- A merge commit not yet linked to its pull request, or a pull request head
  whose push isn't listed yet, is judged again within the activity-lag window.
- Docs: what a merge vouches for beneath it, stacks, --match-head-commit, and
  the reason wording for a pull request's own commits.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…head was

A head a merge put on a watched branch is now judged by the chain of the
branch's activity back from that merge: it is trusted only when every change
since a trusted push, the head the watch last trusted, or the branch's first
change is a trusted merge. A trusted merge is one where every commit it put
on the branch (first parents down to its `before`) is the merge commit of
exactly one merged pull request in the watched repository, and each of those
pull requests passes: a button merge whose GraphQL mergedBy is the activity's
trusted actor, or an auto-merged or queued pull request merged by trusted
logins whose final head is itself trusted. Stacks and merge queue batches
have every pull request judged; a rebase merge's earlier commits are counted
against its pull request's commit count. The walk reads at most 3 activity
pages (cursor-paged via the Link header), 30 changes, 20 commits per merge
and 60 in all; anything else falls back to the signature rule.

A watch now judges every head it watches on its first poll after it is made,
resumed, or given a new trust list, and remembers the ones vouched for.

The one-level "beneath" rule is removed, along with the tests that allowed
what it let through.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Trust generations: each remembered head is stamped with a hash of gh's
  login and the trust list; lastTrusted returns it only when that matches
  the poll's watch and the stored one. A poll re-reads the watch before
  every rememberTrusted, ignore, and baseline clear after an await, and
  skips them when it changed; the fire path remembers after the stale-poll
  check. The baseline stays while any judgment is transient.
- --trust-head (loop watch add, update, resume; trustHead on
  loop.watch.create/update): you vouch for each watched branch's current
  head, remembered under the current generation, audited, and kept in the
  watch's history ("you vouched for main at abc123"). Never implied.
- Every poll judges a watched branch's moved head and remembers it when
  trusted, so the remembered head keeps up with the branch.
- GraphQL logins are read with __typename: a Bot gets its [bot].
- An auto-merged pull request whose final head was pushed before a trusted
  login last turned auto-merge on is vouched for.
- A hole in the activity is crossed (at most three per chain) at a pull
  request merged with the button by a trusted login; the reason names the
  range read only when the record would be past it.
- Commits beneath a merge commit are taken as a rebase's only when they and
  the merge commit are single-parent and match the pull request's commits'
  messages and author dates, in order.
- Docs: --trust-head, web-UI pushes count as your push.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Auto-merge relaxation applies only when the head's push is its branch's
  newest change (or just beneath the branch's deletion at that head); the
  unverifiable expectedHeadOid check is documented as a limitation.
- --trust-head <branch>=<sha> (protocol trustHead: true | {branch: sha})
  vouches as a compare-and-set; every vouch records who put the head there.
- Hole crossing requires the pull request's base to be the watched branch,
  and refuses over a deletion or creation of the branch in the skipped range.
- GraphQL actors other than User and Bot are no one.
- A head the refresh refuses is recorded as an untrusted `head` row, so
  later chains stop there: cost per moved head stays flat.
- A trust list that only adds logins carries remembered heads and anchors
  to its generation; dropping a login still forgets them.
- Comments on a pull request at an untrusted head are held: the comment
  floor doesn't advance, so they run once the head is vouched for.
- Docs and help: vouching, untrusted cost, update/resume help,
  update --trust-none.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- A pull request at a refused head keeps its own comment floor
  (loop_watch_held_prs) instead of freezing the watch's lastOkAt; it is
  read from that floor once its head is trusted, then rejoins the watch's
  floor. A held floor reads back 7 days at most (noted in skipped); it
  survives the PR leaving the newest-30 window and is dropped when it closes.
- Keep the newest 3 `head` rows per branch; `show` always includes each
  branch's newest trust anchor.
- CLI: refuse bare --trust-head mixed with --trust-head <branch>=<sha>, and
  say what to do when --trust-head swallows the watch id.
- A gap whose commit GitHub names no pull request for is transient, judged
  again within the activity lag instead of refused for good.
- The auto-merge relaxation also needs no HEAD_REF_FORCE_PUSHED_EVENT since
  the last enable (unknown refuses), and a push-type head change.
- docs/loops.md: trust-list change keeps a vouched head when it only adds
  logins; held floor, its 7-day cap, head-row retention.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A routine's action can now be a loop run instead of a session (spec.loop:
acceptance checks, tool budget, attempt/time limits). After its --check gate
finds work, the routine starts the loop as its owner through LoopService
with a `routine` trigger (routine id/name, routine run id; gate output and
payload as data), so the run shows in `loop ls` and the loops panel like
any other. The routine run follows the loop (loopRunId/loopStatus) without
holding a run slot and ends with its outcome; a fire while its last loop
run is in progress, or past the owner's loop cap, is skipped. Loop
routines need what loop.start needs, and settings a loop can't honour are
refused. CLI: routine add/edit --loop --verify "id: cmd" [--test]
[--attempts] [--attempt-minutes] [--run-minutes] [--budget], --no-loop.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The loops drawer becomes an Automation drawer with three tabs: Loop runs,
Watches and Routines. Only the visible tab's store polls at the view's pace.

Routines: list (schedule in words, next fire, state, gate, last outcome),
detail (definition, next fires, recent runs with outcomes, session and loop
run links, cancel, run now with payload, pause/resume, API token rotation
shown once, delete), and a create/edit form with a live preview of the next
three fires from routine.preview, the check gate, approvals, budgets, and
the "starts a loop" action (acceptance checks and loop limits; the settings
a loop routine refuses step aside and say why). Edits send only what changed.

Watches: list (repo, branches or my PRs, health, last poll, error/back-off,
runs started), detail (CI mappings, review, trust, history with why a head
was refused), create form, trust editor that explains widening vs narrowing
(narrowing forgets vouched heads, behind a confirm), and a vouch dialog that
pins each branch to a commit (compare-and-set) and re-asks when it moved.

State in state/routines.ts and state/watches.ts mirrors state/loops.ts:
reset on a new connection, cleared on a refused read, background refresh for
the inbox, which now lists failing watches and routines. Loop runs started by
a routine link back to it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Vouch pins are full commit ids only (daemon, protocol schema, CLI); a moved head's refusal names the full id and carries response.error detail head_moved, kept by RequestError in packages/core
- The web always vouches pinned to the full id; the unpinned "narrow and vouch again" is gone, and a narrowing save offers the pinned vouch dialog instead
- A loop routine's loop that ends blocked after attempts counts toward the failure pause; one blocked at its baseline stays silent
- Runs following a loop end failed when loops are gone; the inbox lists a routine whose loop waits on you or is paused (RoutineWire.activeLoopRun)
- Gate output and payload reach the loop worker as two data blocks, each within its own budget (LoopTrigger.payload)
- On restart, a run whose loop started before it was recorded follows it again (owner-scoped LoopService.listActive)
- Loop cancel notifies (kind cancelled) and charges the in-flight attempt's cost; ticks follow loops before fires; a fire re-reads its loop and names its status when skipping
- Routine notify policy governs routine-started loop pushes; the routine end push opens the loop's worker
- The daemon refuses toolBudget/maxMinutes/keepSessions for loop routines on create and edit
- No routine_end for a routine being deleted
- Web: run now on a paused routine, curl Content-Type, once schedules unchanged when untouched, reply ordering and fresh inbox lists in the routine and watch stores, API token held in the store and cleared on connection change, knownHeads matches the watched repo, keepSessions >= 1, mention without whitespace, "loop blocked" label, payload wording for loop routines
- Docs: routines.md, loops.md, CLAUDE.md, CLI help

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@saucam
saucam force-pushed the feat/loop-watches branch from 0ce9ee7 to f311446 Compare October 25, 2026 19:50
- The web never vouches unpinned: the create form's "vouch for each head now" is gone, and a new branch watch offers the pinned vouch dialog instead
- The vouch dialog fills nothing in: a past vouch or a failing check's head is a "Use <id> (view)" suggestion, an untrusted head is never offered, and a moved head needs "I've looked at this commit" before it can be vouched for; githubCommitUrl takes 64-hex ids
- Relink requires the loop's trigger to name the run's routine
- The API fire reply gives a generic reason, never the run's (which can name a loop run)
- A loop cancel re-reads the worker's spend after the interrupted turn drained (Session.turnDrained, bounded) and only then notifies
- RoutineWire.lastOutcome: the inbox, the tab alert, the routine list and detail, and routine ls read it, so a blocked loop isn't hidden behind skipped fires; trimming keeps it
- A fire during delete is refused (fire, #fireScheduled, #admit, #drainQueue, #startLoopRun), so no loop outlives its routine
- A loop routine's run is running before its loop starts, and relink accepts queued, so a crash can't start a second loop
- The inbox doesn't list a waiting loop twice through its routine, and a self-paused routine keeps its pause
- The automation stores reset on an identity change (state/automation-identity.ts), not on a new connection; a routine token survives a reconnect and is shown only to the identity that asked
- Docs: routines.md, loops.md, CLAUDE.md
- test: define the slash-named remote through config (newer git refuses remote add)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@saucam saucam changed the title feat: loop watches — CI failures and review comments start repair runs feat: loop watches, routines that start a loop, and automation in the web UI Oct 25, 2026
@saucam
saucam merged commit bfbad7b into main Oct 25, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants