[F062] work_session.merge_pr: idempotency + active-status guard

merge_pr unconditionally set pr_status=merged, pr_merged_at, merged_by,
status=COMPLETED on whatever session it loaded — the only session-terminal
transition in WorkSessionService lacking both the active-status guard
(complete/abandon) and the terminal-idempotency guard (close). Two failure
modes: (1) a retried merge after a successful-but-unconfirmed GitHub merge
overwrote merged_by/pr_merged_at with the retry's actor/timestamp, corrupting
the merge audit trail; (2) merge_pr on an ABANDONED session resurrected it to
COMPLETED, undoing the single-active abandonment. Mirrors close()'s guard:
if status != ACTIVE, return the session unchanged. Both git.py callers await
merge_pr and discard the return, so the no-op is safe. TDD: 3 tests
(happy-path + both modes).
This commit is contained in:
Renn F
2026-06-28 17:02:04 +02:00
parent f826285651
commit ccf895dd67
2 changed files with 97 additions and 0 deletions
+11
View File
@@ -506,6 +506,17 @@ class WorkSessionService(BaseService):
if not work_session:
return None
# Idempotency + active-guard (F062), mirroring close(): a session that is
# already terminal (COMPLETED — a retried merge after a
# successful-but-unconfirmed GitHub merge; or ABANDONED — a superseded
# session) is returned unchanged. Without this, a retry overwrote
# merged_by / pr_merged_at with the retry's actor/timestamp (corrupting
# the merge audit trail) and an ABANDONED session was resurrected to
# COMPLETED (undoing the single-active abandonment). Not a warning
# because the caller may legitimately double-call on retry/idempotency.
if work_session.status != WorkSessionStatus.ACTIVE:
return work_session
work_session.pr_status = "merged"
work_session.pr_merged_at = datetime.now(UTC)
work_session.merged_by = cast("Any", merged_by)