Skip to content

Commit cee707c

Browse files
johanzanderclaude
andcommitted
fix: stop the rhythm surface reporting live work as stalled, and stale approvals as merge-ready
Two defects, both in the same direction: the surface told the maintainer to act on work that was already handled. REVIEW STATE (addresses the Stage 4 review on this PR, which reproduced it). The previous commit asked "is there an APPROVED review anywhere" before consulting `reviewDecision`. GitHub never rewrites an old review when a later round requests changes, so an approved-then-reworked PR keeps its stale APPROVED entry forever and was reported "nothing left but your merge" — the exact failure the commit set out to close, reintroduced by the fix for it. The review proposed keying on `reviewDecision` instead. That alone is also wrong here, and measurably: `reviewDecision` is only populated when the repo REQUIRES reviews, and this one does not. It reads CHANGES_REQUESTED for #619/#620/#614 but "" for #490, which carries two genuine APPROVED reviews — so keying on it alone reports an approved PR as never reviewed. Neither signal is sufficient, and each fails toward "merge it", so the order is the whole content of the rule: trust `reviewDecision` when set, otherwise fall back to the LAST non-COMMENTED review. Last, not any — same staleness trap. SESSION LIVENESS. `resume_implementation` keyed off `session == null`, and `session` comes from `claude agents`, which lists BACKGROUND agents only. A session started in the terminal — `claude`, then `/implement-issue <n>` — is a foreground session and never appears; even a background agent carries a generated descriptive name rather than the `issue-<n>` the dispatch convention promises. Measured: 41 worktrees on disk, `claude agents --json` returning one entry. So every worktree read as abandoned, and #624 was reported "no live session, /implement-issue 624 to resume" while actively being worked — routing a second session onto a branch the advice itself calls the only copy. The worktree LOCK is what tracks a live session: git records `locked claude session <name> (pid N start ...)`, and 4 of those 41 were locked — exactly the four live sessions, foreground and background alike. Live effect: #626 moves request_review -> rework_review (the review landed), #490 stays awaiting_maintainer despite its empty reviewDecision, and #624 is no longer reported as stalled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012LExo6fcbup75vtc9NfoAR
1 parent eaae079 commit cee707c

4 files changed

Lines changed: 226 additions & 26 deletions

File tree

backend/tests/test_backlog_digest.py

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,15 +59,24 @@ def _run_expect_failure(bin_dir: Path, **extra_env: str) -> subprocess.Completed
5959
)
6060

6161

62-
def _porcelain(*worktrees: tuple[str, str | None]) -> str:
62+
def _porcelain(*worktrees: tuple[str, str | None], locked: tuple[str, ...] = ()) -> str:
6363
"""Build `git worktree list --porcelain` output for a main checkout
64-
followed by the given (path, branch) pairs. branch=None means detached."""
64+
followed by the given (path, branch) pairs. branch=None means detached.
65+
66+
Paths named in `locked` get a `locked` record in the real shape Claude Code
67+
writes it -- `locked claude session <name> (pid N start ...)`, a reason
68+
string rather than the bare keyword, which a `== "locked"` match would miss.
69+
"""
6570
records = [
6671
"worktree /repo\nHEAD 0000000000000000000000000000000000000\nbranch refs/heads/main"
6772
]
6873
for path, branch in worktrees:
6974
lines = [f"worktree {path}", "HEAD 1111111111111111111111111111111111111"]
7075
lines.append(f"branch refs/heads/{branch}" if branch else "detached")
76+
if path in locked:
77+
lines.append(
78+
"locked claude session wt (pid 97626 start Mon Aug 17 15:50:14 2026)"
79+
)
7180
records.append("\n".join(lines))
7281
return "\n\n".join(records) + "\n"
7382

@@ -892,6 +901,46 @@ def test_issue_with_no_card_reports_null_status_and_is_an_orphan(
892901
assert [o["ref"] for o in orphans] == ["624"]
893902

894903

904+
def test_a_locked_worktree_is_reported_as_locked(bin_dir: Path) -> None:
905+
"""The lock is the liveness signal `claude agents` cannot provide: it lists
906+
background agents only, so a foreground `/implement-issue` is invisible and
907+
its worktree reads as abandoned."""
908+
issue = _issue(624)
909+
_write_shim(bin_dir, "gh", _gh_shim([issue], [], []))
910+
_write_shim(
911+
bin_dir,
912+
"git",
913+
_git_shim(
914+
_porcelain(
915+
("/repo/worktrees/issue-624", "fix/issue-624-pwl"),
916+
locked=("/repo/worktrees/issue-624",),
917+
)
918+
),
919+
)
920+
921+
item = _run(bin_dir)["items"][0]
922+
923+
assert item["worktree"] == "/repo/worktrees/issue-624"
924+
assert item["worktree_locked"] is True
925+
# ...and the name-matched session is still null, which is the whole point.
926+
assert item["session"] is None
927+
928+
929+
def test_an_unlocked_worktree_is_reported_as_unlocked(bin_dir: Path) -> None:
930+
issue = _issue(625)
931+
_write_shim(bin_dir, "gh", _gh_shim([issue], [], []))
932+
_write_shim(
933+
bin_dir,
934+
"git",
935+
_git_shim(_porcelain(("/repo/worktrees/issue-625", "fix/issue-625"))),
936+
)
937+
938+
item = _run(bin_dir)["items"][0]
939+
940+
assert item["worktree"] == "/repo/worktrees/issue-625"
941+
assert item["worktree_locked"] is False
942+
943+
895944
def test_issue_with_a_card_is_not_an_orphan(bin_dir: Path) -> None:
896945
issue = _issue(602)
897946
card = {"content": {"number": 602}, "status": "Backlog", "priority": "P1"}

backend/tests/test_backlog_rhythm.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ def _item(number: int, **over: object) -> dict:
4242
"merged_pr": None,
4343
"worktree": None,
4444
"worktree_branch": None,
45+
# A live session holds its worktree locked. Defaults to unlocked so the
46+
# pre-existing stalled-work tests keep asserting what they always did.
47+
"worktree_locked": False,
4548
"stale_worktree": False,
4649
"session": None,
4750
"blocked_by": [],
@@ -247,6 +250,36 @@ def test_a_worktree_with_no_session_is_stalled_work(tmp_path: Path) -> None:
247250
assert "never restart" in action["detail"]
248251

249252

253+
def test_a_locked_worktree_is_never_reported_as_stalled(tmp_path: Path) -> None:
254+
"""`claude agents` lists background agents only, so a foreground
255+
`/implement-issue` started from the terminal is invisible and its worktree
256+
reads as abandoned. #624 was reported "no live session" while actively
257+
being worked, advising a second session onto the same branch. The lock is
258+
what a live session actually holds."""
259+
item = _item(
260+
624,
261+
worktree="/repo/worktrees/fix-issue-624",
262+
worktree_branch="fix/issue-624-pwl-window-bisect",
263+
worktree_locked=True,
264+
session=None,
265+
)
266+
assert "resume_implementation" not in _actions_for(_run(tmp_path, [item]), 624)
267+
268+
269+
def test_an_unlocked_worktree_with_no_session_is_still_stalled(
270+
tmp_path: Path,
271+
) -> None:
272+
"""The lock must not swallow the case this rule exists for."""
273+
item = _item(
274+
625,
275+
worktree="/repo/worktrees/fix-issue-625",
276+
worktree_branch="fix/issue-625",
277+
worktree_locked=False,
278+
session=None,
279+
)
280+
assert "resume_implementation" in _actions_for(_run(tmp_path, [item]), 625)
281+
282+
250283
def test_a_worktree_with_a_live_session_is_left_alone(tmp_path: Path) -> None:
251284
item = _item(
252285
590,
@@ -412,6 +445,67 @@ def test_a_non_draft_with_changes_requested_needs_rework(tmp_path: Path) -> None
412445
assert "awaiting_maintainer" not in actions
413446

414447

448+
def test_a_stale_approval_does_not_survive_a_later_changes_requested(
449+
tmp_path: Path,
450+
) -> None:
451+
"""GitHub never rewrites an old review when a later round requests
452+
changes, so an approved-then-reworked PR keeps its APPROVED entry forever.
453+
Asking "is there an APPROVED anywhere" reported a PR with changes
454+
outstanding as ready to merge — the same failure, reintroduced by the first
455+
attempt at fixing it."""
456+
pr = _pr(
457+
700,
458+
isDraft=False,
459+
reviewDecision="CHANGES_REQUESTED",
460+
reviews=[{"state": "APPROVED"}, {"state": "CHANGES_REQUESTED"}],
461+
)
462+
actions = _actions_for(_run(tmp_path, [], [pr]), 700)
463+
assert "rework_review" in actions
464+
assert "awaiting_maintainer" not in actions
465+
466+
467+
def test_an_approval_is_honoured_when_review_decision_is_empty(
468+
tmp_path: Path,
469+
) -> None:
470+
"""`reviewDecision` is only populated when the repo REQUIRES reviews, and
471+
this one does not — #490 carries two real APPROVED reviews and still reads
472+
"". Keying on reviewDecision alone would report it as never reviewed."""
473+
pr = _pr(
474+
490,
475+
isDraft=False,
476+
reviewDecision="",
477+
reviews=[
478+
{"state": "COMMENTED"},
479+
{"state": "APPROVED"},
480+
{"state": "COMMENTED"},
481+
{"state": "APPROVED"},
482+
],
483+
)
484+
actions = _actions_for(_run(tmp_path, [], [pr]), 490)
485+
assert "awaiting_maintainer" in actions
486+
assert "request_review" not in actions
487+
488+
489+
def test_a_stale_approval_loses_to_changes_requested_without_review_decision(
490+
tmp_path: Path,
491+
) -> None:
492+
"""The same staleness trap with no reviewDecision to lean on: the LAST
493+
non-COMMENTED verdict decides, not the presence of an APPROVED."""
494+
pr = _pr(
495+
701,
496+
isDraft=False,
497+
reviewDecision="",
498+
reviews=[
499+
{"state": "APPROVED"},
500+
{"state": "COMMENTED"},
501+
{"state": "CHANGES_REQUESTED"},
502+
],
503+
)
504+
actions = _actions_for(_run(tmp_path, [], [pr]), 701)
505+
assert "rework_review" in actions
506+
assert "awaiting_maintainer" not in actions
507+
508+
415509
def test_an_approval_followed_by_notes_still_counts(tmp_path: Path) -> None:
416510
"""A trailing COMMENTED must not un-approve a PR — #490 carries exactly
417511
this shape (COMMENTED, APPROVED, COMMENTED, APPROVED)."""

scripts/backlog-digest.sh

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,23 +80,48 @@ merged_prs=$(gh pr list --repo "$repo" --state merged --limit 200 \
8080
refs: [ (.body // "") | scan("(?i)(?:fixes|closes|resolves) #([0-9]+)") | .[0] | tonumber ]
8181
} ]')
8282

83-
# Emits, per worktree, a JSON object of {path, branch}. `git worktree list`
84-
# always emits the main checkout as its first record, and it is excluded
83+
# Emits, per worktree, a JSON object of {path, branch, locked}. `git worktree
84+
# list` always emits the main checkout as its first record, and it is excluded
8585
# here — it is never a task worktree, so it must not appear in the orphan
8686
# scan below.
87+
#
88+
# `locked` is the LIVENESS signal, and it is the only one that works. See the
89+
# session note below.
8790
worktrees=$(git worktree list --porcelain | awk '
8891
BEGIN { RS=""; FS="\n" }
8992
NR==1 { next }
9093
{
91-
path=""; branch=""
94+
path=""; branch=""; locked="false"
9295
for (i = 1; i <= NF; i++) {
9396
if ($i ~ /^worktree /) { path = substr($i, 10) }
9497
else if ($i ~ /^branch /) { branch = substr($i, 8); sub(/^refs\/heads\//, "", branch) }
98+
else if ($i ~ /^locked/) { locked = "true" }
9599
}
96-
if (path != "") print path "\t" branch
100+
if (path != "") print path "\t" branch "\t" locked
97101
}
98-
' | jq -R 'split("\t") | {path: .[0], branch: (.[1] // "")}' | jq -s .)
102+
' | jq -R 'split("\t") | {path: .[0], branch: (.[1] // ""), locked: (.[2] == "true")}' | jq -s .)
99103

104+
# `claude agents` lists BACKGROUND agents only, and this is the trap that made
105+
# the rhythm pass tell the maintainer to restart work that was actively
106+
# running. Two independent reasons it cannot answer "is someone on this":
107+
#
108+
# 1. A session started in the terminal — `claude` in a CLI, then
109+
# `/implement-issue <n>` — is a foreground session and never appears here
110+
# at all. That is how #624 was dispatched.
111+
# 2. Even a background agent carries a generated descriptive name ("Review PR
112+
# and create branch for bess-manager"), not the `issue-<n>` the dispatch
113+
# convention promises, so the exact-name match below misses it too.
114+
#
115+
# Measured: 41 worktrees on disk, `claude agents --json` returning ONE entry.
116+
# So `session` was null for essentially every item, every worktree read as
117+
# abandoned, and `resume_implementation` fired on live sessions — against work
118+
# whose branch commits the skill itself calls the only copy.
119+
#
120+
# The worktree LOCK is the signal that actually tracks a live session: 4 of
121+
# those 41 were locked, and they were exactly the four live sessions. It is
122+
# local, needs no process list, and covers foreground and background alike.
123+
# `session` is kept because a name match is strictly more informative when it
124+
# does happen; it is no longer what liveness rests on.
100125
sessions=$(claude agents --json)
101126

102127
# No `--field "Priority"` here: verified against the real CLI just now,
@@ -380,6 +405,10 @@ jq -n \
380405
merged_pr: $merged_pr,
381406
worktree: ($wt.path // null),
382407
worktree_branch: ($wt.branch // null),
408+
# A LIVE session holds its worktree locked. This, not `session`, is
409+
# what says whether anyone is on the item — see the note where the
410+
# worktree list is built.
411+
worktree_locked: ($wt.locked // false),
383412
# A worktree whose own branch has already merged is rot, and
384413
# `sweep-prs` is what removes it. Flagged so a board pass reports it
385414
# instead of reading it as active work: #593, #571, #542 and #466 all

scripts/backlog-rhythm.sh

Lines changed: 47 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -171,9 +171,18 @@ actions=$(printf '%s' "$digest" | jq \
171171
# The branch survives, so this is a resume and never a restart -- Step 0
172172
# re-enters at the earliest incomplete step. Restarting would run Step 4,
173173
# which branches fresh from origin/main and would delete those commits.
174-
(if .worktree != null and (.stale_worktree | not) and .session == null and .pr == null
174+
#
175+
# A LOCKED worktree means a session is holding it right now, and that check
176+
# is what stops this rule from firing on live work. `session` alone could
177+
# not: `claude agents` sees background agents only, so a foreground
178+
# `/implement-issue` started from the terminal was invisible and its
179+
# worktree read as abandoned. #624 was reported "no live session" while
180+
# actively being worked, with the advice to re-enter it -- a second session
181+
# on the same branch, against work the detail line itself calls the only
182+
# copy.
183+
(if .worktree != null and (.stale_worktree | not) and (.worktree_locked | not) and .session == null and .pr == null
175184
then {issue: .number, action: "resume_implementation",
176-
why: "worktree \(.worktree_branch) on disk, no live session",
185+
why: "worktree \(.worktree_branch) on disk, unlocked, no live session",
177186
detail: "/implement-issue \(.number) — Step 0 resumes it; never restart, the branch commits are the only copy"}
178187
else empty end),
179188
@@ -222,24 +231,43 @@ actions=$(printf '%s' "$digest" | jq \
222231
# 11 at all. Stage 4 is the gate the whole pipeline is built around;
223232
# a surface that routes around it is worse than no surface.
224233
#
225-
# An APPROVED review is the bar, matching Step 11 exactly. Not
226-
# `reviewDecision`, which is "" both for never-reviewed and for
227-
# approved-then-dismissed, and not COMMENTED, which the bot also
228-
# posts as a placeholder before its real verdict (see
229-
# request-pr-review.sh).
234+
# NEITHER SIGNAL ALONE IS CORRECT, and each fails in the merge-happy
235+
# direction, so the ORDER below is the whole content of this rule.
236+
#
237+
# `reviews[]` is history, not state: GitHub never rewrites an old
238+
# review when a later round requests changes, so an
239+
# approved-then-reworked PR keeps its stale APPROVED entry forever.
240+
# Asking "is there an APPROVED anywhere" first therefore reported
241+
# "nothing left but your merge" for a PR with changes outstanding --
242+
# exactly the failure this rule exists to prevent, reintroduced by the
243+
# first attempt at fixing it.
244+
#
245+
# `reviewDecision` is the current state, but it is only populated when
246+
# the repo REQUIRES reviews, and this one does not: it reads
247+
# CHANGES_REQUESTED for #619/#620/#614 and "" for #490, which carries
248+
# two real APPROVED reviews. So keying on it alone would report a
249+
# genuinely approved PR as never reviewed.
250+
#
251+
# Hence: trust `reviewDecision` whenever it is set, and only then fall
252+
# back to the LAST non-COMMENTED review. Last, not any -- same staleness
253+
# trap. COMMENTED is skipped because the bot posts its inline notes as
254+
# a COMMENTED review before its real verdict (see request-pr-review.sh).
230255
elif (.isDraft | not)
231-
then (if ([ .reviews[]? | select(.state == "APPROVED") ] | length) > 0
232-
then {pr: .number, action: "awaiting_maintainer",
233-
why: "out of draft and approved",
234-
detail: "nothing left but your merge"}
235-
elif .reviewDecision == "CHANGES_REQUESTED"
236-
then {pr: .number, issue: $issue_no, action: "rework_review",
237-
why: "out of draft but review asked for changes",
238-
detail: "address the review, then request a fresh one"}
239-
else {pr: .number, issue: $issue_no, action: "request_review",
240-
why: "out of draft but never reviewed — Stage 4 has not run",
241-
detail: "scripts/request-pr-review.sh \(.number) — do NOT merge on the draft flag alone"}
242-
end)
256+
then ([ .reviews[]? | select(.state != "COMMENTED") ] | last | .state?) as $last_verdict
257+
| (if .reviewDecision == "CHANGES_REQUESTED" or
258+
(.reviewDecision == "" and $last_verdict == "CHANGES_REQUESTED") or
259+
(.reviewDecision == null and $last_verdict == "CHANGES_REQUESTED")
260+
then {pr: .number, issue: $issue_no, action: "rework_review",
261+
why: "out of draft but the current review asks for changes",
262+
detail: "address the review, then request a fresh one"}
263+
elif .reviewDecision == "APPROVED" or $last_verdict == "APPROVED"
264+
then {pr: .number, action: "awaiting_maintainer",
265+
why: "out of draft and approved",
266+
detail: "nothing left but your merge"}
267+
else {pr: .number, issue: $issue_no, action: "request_review",
268+
why: "out of draft but never reviewed — Stage 4 has not run",
269+
detail: "scripts/request-pr-review.sh \(.number) — do NOT merge on the draft flag alone"}
270+
end)
243271
else {pr: .number, issue: $issue_no, action: "resume_implementation",
244272
why: "draft PR, review loop unfinished",
245273
# `implement-issue` is used for TODO.md items and refactors too,

0 commit comments

Comments
 (0)