Repository navigation
feat: loop watches, routines that start a loop, and automation in the web UI - #427
Merged
Merged
Conversation
abhisheksoamj
approved these changes
Oct 25, 2026
`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
force-pushed
the
feat/loop-watches
branch
from
October 25, 2026 19:50
0ce9ee7 to
f311446
Compare
- 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
@codeoidon one of your open pull requests.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
--checkgate still decides whether there is work, at no cost.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" indocs/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:
--trust, is trusted, according to GitHub's repository activity (a push vouches for everything beneath it).web-flowvouches for no one).--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.Loop routines
session:create,session:sendandsession:approve. Delegated tokens are refused; pausing stays allowed as a safety stop.{{…}}placeholders.--daily-cost.Hardening (watches)
ghas the only credential, and the pack is added offline. Shallow and partial clones are refused.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