diff --git a/backend/internal/service/session/status.go b/backend/internal/service/session/status.go index 1bacbbde1e..77f976144b 100644 --- a/backend/internal/service/session/status.go +++ b/backend/internal/service/session/status.go @@ -26,7 +26,11 @@ const noSignalGrace = 90 * time.Second // until the parent does. Merged/closed PRs only matter once no open PR remains. func deriveStatus(rec domain.SessionRecord, prs []domain.PRFacts, now time.Time, signalCapable bool) domain.SessionStatus { if rec.IsTerminated { - if anyMerged(prs) { + // Merged only wins once no open PR remains: a session killed while it + // still owns an open PR (e.g. only the bottom of a stack merged) did not + // complete, and reporting it as merged would hide the abandoned open PR. + // The non-terminated branch below enforces the same ordering. + if len(openPRs(prs)) == 0 && anyMerged(prs) { return domain.StatusMerged } return domain.StatusTerminated diff --git a/backend/internal/service/session/status_test.go b/backend/internal/service/session/status_test.go index f989ed49ca..6e885af328 100644 --- a/backend/internal/service/session/status_test.go +++ b/backend/internal/service/session/status_test.go @@ -41,6 +41,17 @@ func TestServiceDerivesStatusFromSessionFactsAndPR(t *testing.T) { }{ {"terminated", statusRec(domain.ActivityExited, true), nil, false, domain.StatusTerminated}, {"merged-pr", statusRec(domain.ActivityIdle, true), statusPR(domain.PRFacts{Merged: true}), false, domain.StatusMerged}, + // A terminated session that still owns an open PR did not complete: the + // merged short-circuit must not fire while an open PR remains, matching + // the invariant that merged only wins once no open PR is left (the + // non-terminated branch already enforces this). + { + "terminated-with-open-pr-stays-terminated", + statusRec(domain.ActivityIdle, true), + []domain.PRFacts{{URL: "merged", Merged: true}, {URL: "open", SourceBranch: "ao/x", TargetBranch: "main"}}, + false, + domain.StatusTerminated, + }, {"needs-input", statusRec(domain.ActivityWaitingInput, false), statusPR(domain.PRFacts{CI: domain.CIFailing}), false, domain.StatusNeedsInput}, {"ci-failed", statusRec(domain.ActivityIdle, false), statusPR(domain.PRFacts{CI: domain.CIFailing}), false, domain.StatusCIFailed}, {"draft", statusRec(domain.ActivityIdle, false), statusPR(domain.PRFacts{Draft: true}), false, domain.StatusDraft},