--- paths: - src/bot.py --- # The collaborator push watch Read this before editing the watch in `src/bot.py`. It answers "why is it written this way", which is not obvious from the code and is where the bugs were. **What it is:** opt-in per repo (`[repo.].watch_pushes`), it notices commits pushed by anyone who isn't me and posts an assessment of them. Shipped 2026-08-21 for ten31-brand, where a collaborator with write access pushes straight to `master`. ## Notify first, assess second The notice is plain Gitea data — author, branch, commit subjects, files, links — and the "outside the expected area" flag is computed from `expected_paths` globs, never asked of the model. So the thing you actually need (someone pushed, and where) never depends on a `claude -p` call that can be slow, fail, or be wrong. The assessment is a threaded reply under it. **The batch is recorded in `state/` before the assessment starts.** This is the ordering, and it is load-bearing: a send error, a container update or the poll-task guard firing in the minutes an assessment takes would otherwise leave the commits unrecorded, and the next cycle would repost the whole notice and re-run the assessment. The accepted cost is the opposite failure — a crash mid-assessment leaves a bare "_Assessing…_" in the room with no retry. A missing assessment is visible; a duplicate notice is noise. (Found by a reviewer pass, not in production.) ## The `since` cursor (`state/rooms.json` → `watch: {since, seen[]}`) `since` is Gitea's `since` query param; `seen` is the last `WATCH_SEEN_MAX` (500) SHAs accounted for. Four rules, each protecting against a way the watch goes quiet or repeats itself: - **Trails the newest commit by `WATCH_LOOKBACK_HOURS` (24).** A commit date comes from the committer's machine, so a clock a few hours behind would otherwise land a commit *before* the cursor and hide it forever. - **Clamped to now.** Without it a single future-dated commit parks the cursor ahead of every real push and silences the watch permanently. - **Never moves backwards**, and is **floored** at the oldest commit that was listed but not notified (a notice that failed to post, or a batch deferred by the per-cycle cap), so a held-over push stays listable instead of ageing out of the window unseen. - **Gives up after `WATCH_GIVEUP_HOURS` (48)** and logs what it dropped, so a permanently unsendable batch (lost send power, a tombstoned room) can't freeze the cursor for good. On first run the watch arms at "now" — it never replays history into a room. To force a test, stop the container, backdate `watch.since`, start it. `state/` is root-owned on the Spark and `sudo` there wants a password, so edit it with a throwaway root container: `docker run --rm -v "$PWD/state:/s" python:3.12-slim python -c '...'`. ## Scope, dedupe, and not repeating the PR review - Every branch **except** ones with an open PR — that PR's review already covers those commits. - The **default branch is walked first** (looked up once and cached in `room["default_branch"]`), because a commit is usually reachable from several branches and gets reported under whichever is walked first. "3 new commits on master" is the true answer for anything that has landed. - **Merging or closing a PR banks its commits** (`remember_pr_commits`, including the merge commit Gitea creates, which is not in the PR's own commit list). This runs on the in-thread merge/close path *before* the deploy — a deploy can take `DEPLOY_TIMEOUT` and a poll cycle landing inside that window would see the merged commits as a fresh push — and on the poll loop's stale-PR path, which is what catches a merge done in Gitea's web UI. ## Bounds (one push must not stall every room) The poll loop is one serial async task, so an assessment blocks every other room. Hence `WATCH_MAX_BATCHES` (2) assessments per repo per cycle, each bounded by `[defaults] watch_timeout_seconds` (480s). **Basis for 480:** a real two-commit assessment with one panel subagent measured 173s wall clock on 2026-08-21. Worst case is therefore ~16 min of held polling — the head-of-line cost ROADMAP Phase 3 tracks, now bounded rather than open-ended. Deferred batches keep their commits out of `seen` and pin the cursor, so they go out next cycle. `list_branch_commits` pages to `WATCH_MAX_PAGES × WATCH_COMMIT_LIMIT` (150) commits per branch per cycle. Past that it says so **in the room**, not just the log — a silent cap reads as "nothing to report", which is the one thing this feature must never do. Same rule for a commit whose file list Gitea didn't return: the path check is then incomplete and must never print the all-clear. ## Identity `commit_is_mine` reads the **git email** against `[gitea] my_emails`, because these repos' commits aren't linked to Gitea accounts — the API returns `author: null`. Anything unrecognised counts as someone else's, so a new collaborator notifies rather than slips through. A linked Gitea account matching the repo owner also counts as mine, for repos where linking does work. **This is not an authentication boundary.** Anyone can set `user.email`. It is an accountability aid for a cooperative collaborator. ## Untrusted input A PR's title and description, and a pushed commit's subjects, filenames and diff, are written by someone else and reach `claude -p` on the Mac, in the owner's checkout, under the owner's OAuth token. Both prompt builders put **every** such field inside one `UNTRUSTED DATA ` fence (`untrusted_fence`), with a per-run random tag so a forged terminator can't close it, and instruct the model to report rather than obey anything inside. The instruction region carries only values the attacker doesn't control: the repo label and globs from our own config, the PR number, branch refs (git forbids a newline in one), and counts. A first version fenced only the diff while the commit listing and PR body sat above it — escapable with a forged terminator, caught by a reviewer pass. `tests/test_watch.py` now asserts *placement* (hostile text lands below the BEGIN marker), not the presence of a marker. The remaining half is an explicit read-only tool policy on that run; it needs a matrix-bridge change and is the top ROADMAP item. ## Gitea API facts (verified against 1.27.1, 2026-08-21) - `GET /repos/{o}/{r}/commits?sha=&since=&page=&limit=&files=true&stat=false&verification=false` — `since` wants RFC3339; emit it as `...Z` (`iso_z`) so a `+00:00` offset never has to survive URL encoding, and `urllib.parse.quote(branch, safe="")` because a branch name can contain `+`. - `GET /repos/{o}/{r}/git/commits/{sha}.diff` with `Accept: text/plain` for a single commit's diff. - `GET /repos/{o}/{r}/pulls/{n}/commits` and `GET /repos/{o}/{r}/pulls/{n}` (`merged_commit_id`). - The live token is scoped `write:repository`, which covers all of the above. ## What the tests cover, and what they don't `python3 tests/test_watch.py` (no pytest, no deps; stubs matrix-nio, which the policy code doesn't need) covers the pure policy: who gets reported, cross-branch dedupe, replay suppression, the cursor's four rules, the path flag, and fence placement — against a sanitized five-branch fixture whose branches genuinely overlap, since that overlap is what dedupe is for. 17 mutations of `src/bot.py` were each confirmed to flip it to FAIL (2026-08-21). It does **not** cover the orchestration in `watch_repo` — record-before-assess ordering, the per-cycle cap, held batches — because that lives in closures over the live Matrix and SSH clients. Those were verified by exercising the bot in the real room: the commit was already in `state/rooms.json` while the assessment's `ssh mac-bridge` process was still alive.