Handoff: move the watch internals to a scoped guide, prune Current state
AGENTS.md had grown 72% in one session, most of it push-watch mechanics that only matter when editing src/bot.py. Those move to docs/guides/push-watch.md (paths-scoped, symlinked from .claude/rules, matching the ten31-database and spark-control convention); AGENTS.md keeps the operational half and a pointer. Current state goes back to being a snapshot rather than a session log.
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
---
|
||||
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.<name>].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 <nonce>` 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=<branch>&since=<RFC3339>&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.
|
||||
Reference in New Issue
Block a user