Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

### Fixed

- **Backlog rhythm no longer re-proposes a resume while a session is live in the worktree** — the digest now matches sessions by their launch directory, not only by `issue-<n>` name. ([#647](https://github.com/johanzander/bess-manager/issues/647))
- **Beta release changelog merges no longer absorb the new section into the previous one** — the merge is now resolved deterministically instead of by hand. ([#648](https://github.com/johanzander/bess-manager/issues/648))

## [10.1.0] - 2026-08-22
Expand Down
161 changes: 161 additions & 0 deletions backend/tests/test_backlog_digest.py
Original file line number Diff line number Diff line change
Expand Up @@ -1141,6 +1141,167 @@ def test_an_unlocked_worktree_is_reported_as_unlocked(bin_dir: Path) -> None:
assert item["worktree_locked"] is False


def test_a_dispatched_session_named_issue_n_is_associated(bin_dir: Path) -> None:
"""#647 acceptance: a `claude --bg -n "issue-N"` dispatch from the main
checkout is associated by its name even though its `cwd` is the launch
directory, not the worktree the session goes on to work in.

The rhythm's stalled-work rule fires `resume_implementation` whenever a
worktree is on disk, unlocked, and `.session` is null — so a live dispatch
that reads as null gets a second session launched against work the skill
calls the only copy. The exact-name match is what stops that."""
issue = _issue(631, labels=[{"name": "bug"}])
_write_shim(bin_dir, "gh", _gh_shim([issue], [], []))
_write_shim(
bin_dir,
"git",
_git_shim(_porcelain(("/repo/wt/631", "fix/issue-631"))),
)
_write_shim(
bin_dir,
"claude",
'echo \'[{"name": "issue-631", "cwd": "/repo", '
'"kind": "background", "state": "blocked"}]\'',
)

item = _run(bin_dir)["items"][0]

assert item["worktree"] == "/repo/wt/631"
assert item["session"] == "issue-631"


def test_a_session_inside_a_worktree_is_associated_with_its_issue(
bin_dir: Path,
) -> None:
"""#647, second half: the digest used to associate a session with an issue
ONLY when its name was exactly `issue-<n>`. A session started by hand
inside a worktree carries a generated name (e.g. `bess-manager-84`), so it
was invisible and its unlocked worktree read as abandoned — the rhythm
proposed `resume_implementation` on live work.

The session's `cwd` IS the worktree it is serving, so the worktree-path
join (which already associates a worktree with its issue) also associates
the session. This is the issue's candidate fix 2: match on cwd as well as
name."""
issue = _issue(630, labels=[{"name": "bug"}])
_write_shim(bin_dir, "gh", _gh_shim([issue], [], []))
_write_shim(
bin_dir,
"git",
_git_shim(
_porcelain(
("/repo/worktrees/issue-630-something", "fix/issue-630-something")
)
),
)
_write_shim(
bin_dir,
"claude",
'echo \'[{"name": "bess-manager-84", '
'"cwd": "/repo/worktrees/issue-630-something", '
'"kind": "interactive", "status": "idle"}]\'',
)

item = _run(bin_dir)["items"][0]

assert item["worktree"] == "/repo/worktrees/issue-630-something"
assert item["session"] == "bess-manager-84"


def test_a_dispatch_named_session_launched_inside_another_worktree_is_not_misattributed(
bin_dir: Path,
) -> None:
"""#647, reviewer confirmed: a `claude --bg -n "issue-<m>"` dispatch
launched from inside <n>'s worktree carries that worktree as its `cwd`, so
a cwd-only join would attribute it to <n> too. The exact-name match is
authoritative: the session belongs to <m>, and <n>'s genuinely stalled work
must still read as having no live session."""
issue = _issue(630, labels=[{"name": "bug"}])
_write_shim(bin_dir, "gh", _gh_shim([issue], [], []))
_write_shim(
bin_dir,
"git",
_git_shim(
_porcelain(
("/repo/worktrees/issue-630-something", "fix/issue-630-something")
)
),
)
_write_shim(
bin_dir,
"claude",
'echo \'[{"name": "issue-700", '
'"cwd": "/repo/worktrees/issue-630-something", '
'"kind": "background", "state": "running"}]\'',
)

item = _run(bin_dir)["items"][0]

assert item["worktree"] == "/repo/worktrees/issue-630-something"
assert item["session"] is None


def test_a_session_launched_from_a_worktree_subdirectory_is_associated(
bin_dir: Path,
) -> None:
"""The cwd join matches a path prefix, so a session launched from a
subdirectory of the worktree is still associated with it."""
issue = _issue(630, labels=[{"name": "bug"}])
_write_shim(bin_dir, "gh", _gh_shim([issue], [], []))
_write_shim(
bin_dir,
"git",
_git_shim(
_porcelain(
("/repo/worktrees/issue-630-something", "fix/issue-630-something")
)
),
)
_write_shim(
bin_dir,
"claude",
'echo \'[{"name": "bess-manager-84", '
'"cwd": "/repo/worktrees/issue-630-something/backend", '
'"kind": "interactive", "status": "idle"}]\'',
)

item = _run(bin_dir)["items"][0]

assert item["worktree"] == "/repo/worktrees/issue-630-something"
assert item["session"] == "bess-manager-84"


def test_a_generated_name_session_in_the_main_checkout_matches_nothing(
bin_dir: Path,
) -> None:
"""The main checkout is skipped when $worktrees is built, so a session
launched there -- even one with a generated name that would match by cwd
inside a worktree -- has no worktree to join to and stays null."""
issue = _issue(630, labels=[{"name": "bug"}])
_write_shim(bin_dir, "gh", _gh_shim([issue], [], []))
_write_shim(
bin_dir,
"git",
_git_shim(
_porcelain(
("/repo/worktrees/issue-630-something", "fix/issue-630-something")
)
),
)
_write_shim(
bin_dir,
"claude",
'echo \'[{"name": "bess-manager-84", '
'"cwd": "/repo", '
'"kind": "interactive", "status": "idle"}]\'',
)

item = _run(bin_dir)["items"][0]

assert item["worktree"] == "/repo/worktrees/issue-630-something"
assert item["session"] is None


def test_issue_with_a_card_is_not_an_orphan(bin_dir: Path) -> None:
issue = _issue(602)
card = {"content": {"number": 602}, "status": "Backlog", "priority": "P1"}
Expand Down
66 changes: 48 additions & 18 deletions scripts/backlog-digest.sh
Original file line number Diff line number Diff line change
Expand Up @@ -134,27 +134,30 @@ worktrees=$(git worktree list --porcelain | awk '
}
' | jq -R 'split("\t") | {path: .[0], branch: (.[1] // ""), locked: (.[2] == "true")}' | jq -s .)

# `claude agents` lists BACKGROUND agents only, and this is the trap that made
# the rhythm pass tell the maintainer to restart work that was actively
# running. Two independent reasons it cannot answer "is someone on this":
# `claude agents --json` used to list background agents only, and that is the
# trap that made the rhythm pass tell the maintainer to restart work that was
# actively running. A session started in the terminal never appeared, and even
# a background agent carried a generated descriptive name ("Review PR and
# create branch for bess-manager"), not the `issue-<n>` the dispatch
# convention promises, so the exact-name match missed it too.
#
# 1. A session started in the terminal — `claude` in a CLI, then
# `/implement-issue <n>` — is a foreground session and never appears here
# at all. That is how #624 was dispatched.
# 2. Even a background agent carries a generated descriptive name ("Review PR
# and create branch for bess-manager"), not the `issue-<n>` the dispatch
# convention promises, so the exact-name match below misses it too.
#
# Measured: 41 worktrees on disk, `claude agents --json` returning ONE entry.
# So `session` was null for essentially every item, every worktree read as
# abandoned, and `resume_implementation` fired on live sessions — against work
# whose branch commits the skill itself calls the only copy.
# Measured at the time: 41 worktrees on disk, `claude agents --json` returning
# ONE entry. So `session` was null for essentially every item, every worktree
# read as abandoned, and `resume_implementation` fired on live sessions —
# against work whose branch commits the skill itself calls the only copy.
#
# The worktree LOCK is the signal that actually tracks a live session: 4 of
# those 41 were locked, and they were exactly the four live sessions. It is
# local, needs no process list, and covers foreground and background alike.
# `session` is kept because a name match is strictly more informative when it
# does happen; it is no longer what liveness rests on.
# `session` is kept because a match is strictly more informative when it does
# happen; it is no longer what liveness rests on. The CLI now reports
# interactive sessions too, with their launch `cwd`, so the match is two-fold
# (#647): the exact `issue-<n>` name covers the documented `claude --bg
# -n "issue-<n>"` dispatch (whose cwd is the launch directory, not the
# worktree), and a cwd inside a worktree covers a session started there — by
# hand, or a dispatch launched from inside it — whose generated name carries
# no issue number. A session whose name IS `issue-<m>` always belongs to <m>,
# never to the worktree, so the two cannot collide.
sessions=$(claude agents --json)

# No `--field "Priority"` here: verified against the real CLI just now,
Expand Down Expand Up @@ -256,9 +259,36 @@ jq -n \
and ($wt.branch // "") != ""
and ([ $merged_prs[] | select(.headRefName == $wt.branch) ] | length) > 0;

# A session cwd is the directory it was launched from, which for a session
# started inside a worktree IS that worktree (the CLI reports interactive
# sessions too, not only background agents). Match it exactly, or as a path
# prefix (a cwd inside a worktree subdirectory), so the same path-or-branch
# join that associates a worktree with its issue also associates the session
# living there. The main checkout is never a member of $worktrees -- it is
# skipped when the list is built -- so a session launched from it matches
# nothing here.
#
# The exact-name match is authoritative when it exists: a session whose name
# IS `issue-<m>` belongs to <m>, never to the worktree. Without that
# precedence, a `claude --bg -n "issue-<m>"` dispatch launched from inside
# the worktree of <n> would also populate the session of <n>, masking
# genuinely stalled work on <n> -- the exact failure class this PR fixes,
# inverted.
def session_is_in_worktree($s; $wt):
(($s.cwd // "") == $wt.path) or (($s.cwd // "") | startswith(($wt.path + "/")));

def session_for($n):
([ $sessions[] | select(.name? == "issue-\($n)") | .name ]) as $matches
| if ($matches | length) == 0 then null else $matches[0] end;
([ $sessions[] | . as $s
| select(
($s.name? == "issue-\($n)")
or (
(($s.name? // "") | test("^issue-[0-9]+$") | not)
and any($worktrees[] | select(matches_issue(.; $n)); session_is_in_worktree($s; .))
)
)
| ($s.name // $s.cwd)
]) as $matches
| if ($matches | length) == 0 then null else ($matches | sort | .[0]) end;

# Matched per LINE and anchored to its start, because `Blocked by #N` is a
# convention -- a line in the body, optionally bulleted -- not a phrase to be
Expand Down
Loading