PR 2 of the state-machine review. Closes#58, closes#59.
Comment cursors. Each watched thread records the ID of the last comment taken up: board.last_comment for the plan issue (the field existed but was never updated) and pr.last_comment for the PR. The cursor is saved before the comment is acted on, so each comment is handled at most once, even across a crash. CommentPoller.resume_after(id) skips everything at or below it, so re-attaching needs no request, and an outage at boot can't cause a replay.
Changes (control.py, poller.py)
Boot re-attaches the watches (#59): the plan issue at an open gate, the PR in PR_OPEN, each from its cursor. Comments posted while the agent was down are delivered, as agreed. Approvals stay bound to a revision.
A restart in PR_OPEN resumes with "restarted with PR #N open", not the restart question (#58). A restart question already saved for PR_OPEN returns to it on either reply, instead of re-planning the milestone.
Review failures stay with the PR: a stopped or failed review response gets its own pending kind (review_stopped), and the reply returns to PR_OPEN. Previously it went back to milestone sign-off, where start milestone would open a second PR.
Comments ignored because of the state (e.g. a review comment while the agent waits on a question) don't move the cursor, so they're delivered once it's back in PR_OPEN. Previously they were lost.
A new plan revision still skips earlier comments, so an approve posted before rev-N can't approve it. Data saved before this change has no cursor and gets the same treatment on restart, so an old approval can't approve the current revision.
Tests (test_pr_open_handling.py, real CommentPoller against a fake Gitea, polled explicitly):
an approval posted during downtime is applied after the restart;
a handled approval isn't replayed;
a new revision ignores an earlier approval;
legacy data skips old comments;
a restart in PR_OPEN resumes and handles a review comment posted during downtime;
a failed review response returns to the PR, and a comment posted while waiting is delivered.
5 of the 6 fail on main; the revision test guards behaviour main already had. 455 unit tests and the integration suite (19 passed, 7 skipped) pass; lint and format clean.
Noticed, not changed here (filed separately): Gitea's inline review comments aren't seen by the poller, which reads issue comments only. Also, a Gitea approval that leads to a new plan post re-attaches the plan watch from inside that watch's own callback. The old poller's stop() then waits out its join timeout (~25 s) before continuing, which delays but doesn't break things.
PR 2 of the state-machine review. Closes #58, closes #59.
**Comment cursors.** Each watched thread records the ID of the last comment taken up: `board.last_comment` for the plan issue (the field existed but was never updated) and `pr.last_comment` for the PR. The cursor is saved before the comment is acted on, so each comment is handled at most once, even across a crash. `CommentPoller.resume_after(id)` skips everything at or below it, so re-attaching needs no request, and an outage at boot can't cause a replay.
**Changes** (`control.py`, `poller.py`)
- **Boot re-attaches the watches (#59):** the plan issue at an open gate, the PR in `PR_OPEN`, each from its cursor. Comments posted while the agent was down are delivered, as agreed. Approvals stay bound to a revision.
- **A restart in `PR_OPEN` resumes** with "restarted with PR #N open", not the restart question (#58). A restart question already saved for `PR_OPEN` returns to it on either reply, instead of re-planning the milestone.
- **Review failures stay with the PR:** a stopped or failed review response gets its own pending kind (`review_stopped`), and the reply returns to `PR_OPEN`. Previously it went back to milestone sign-off, where `start milestone` would open a second PR.
- **Comments ignored because of the state** (e.g. a review comment while the agent waits on a question) don't move the cursor, so they're delivered once it's back in `PR_OPEN`. Previously they were lost.
- **A new plan revision still skips earlier comments**, so an `approve` posted before rev-N can't approve it. **Data saved before this change** has no cursor and gets the same treatment on restart, so an old approval can't approve the current revision.
**Tests** (`test_pr_open_handling.py`, real `CommentPoller` against a fake Gitea, polled explicitly):
- an approval posted during downtime is applied after the restart;
- a handled approval isn't replayed;
- a new revision ignores an earlier approval;
- legacy data skips old comments;
- a restart in `PR_OPEN` resumes and handles a review comment posted during downtime;
- a failed review response returns to the PR, and a comment posted while waiting is delivered.
5 of the 6 fail on `main`; the revision test guards behaviour `main` already had. 455 unit tests and the integration suite (19 passed, 7 skipped) pass; lint and format clean.
**Noticed, not changed here** (filed separately): Gitea's inline review comments aren't seen by the poller, which reads issue comments only. Also, a Gitea approval that leads to a new plan post re-attaches the plan watch from inside that watch's own callback. The old poller's `stop()` then waits out its join timeout (~25 s) before continuing, which delays but doesn't break things.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
- Record a comment cursor per thread (board.last_comment, the field that
already existed unused; pr.last_comment), advanced durably when a
comment is taken up. CommentPoller.resume_after() skips comments at or
below it, so re-attaching needs no request and cannot replay.
- Boot re-attaches the plan watch at an open gate and the review watch in
PR_OPEN from their cursors, catching up on comments posted while the
agent was down (#59). Data saved without a cursor skips what is there,
so an old approval cannot approve the current revision.
- A restart in PR_OPEN resumes with a notice instead of the restart
question (#58). A stored restart question for PR_OPEN returns to it on
either reply instead of re-planning the milestone.
- A stopped or failed review response gets its own pending kind; the
reply returns to PR_OPEN and resumes the watch from the cursor, so
comments that arrived meanwhile are delivered. It used to return to
milestone sign-off, where starting the milestone would open a second PR.
- Comments ignored because of the state leave the cursor alone, so they
are delivered once the agent is back where they apply.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
PR 2 of the state-machine review. Closes #58, closes #59.
Comment cursors. Each watched thread records the ID of the last comment taken up:
board.last_commentfor the plan issue (the field existed but was never updated) andpr.last_commentfor the PR. The cursor is saved before the comment is acted on, so each comment is handled at most once, even across a crash.CommentPoller.resume_after(id)skips everything at or below it, so re-attaching needs no request, and an outage at boot can't cause a replay.Changes (
control.py,poller.py)PR_OPEN, each from its cursor. Comments posted while the agent was down are delivered, as agreed. Approvals stay bound to a revision.PR_OPENresumes with "restarted with PR #N open", not the restart question (#58). A restart question already saved forPR_OPENreturns to it on either reply, instead of re-planning the milestone.review_stopped), and the reply returns toPR_OPEN. Previously it went back to milestone sign-off, wherestart milestonewould open a second PR.PR_OPEN. Previously they were lost.approveposted before rev-N can't approve it. Data saved before this change has no cursor and gets the same treatment on restart, so an old approval can't approve the current revision.Tests (
test_pr_open_handling.py, realCommentPolleragainst a fake Gitea, polled explicitly):PR_OPENresumes and handles a review comment posted during downtime;5 of the 6 fail on
main; the revision test guards behaviourmainalready had. 455 unit tests and the integration suite (19 passed, 7 skipped) pass; lint and format clean.Noticed, not changed here (filed separately): Gitea's inline review comments aren't seen by the poller, which reads issue comments only. Also, a Gitea approval that leads to a new plan post re-attaches the plan watch from inside that watch's own callback. The old poller's
stop()then waits out its join timeout (~25 s) before continuing, which delays but doesn't break things.🤖 Generated with Claude Code
https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE