Allow exact-owner renewal of expired author issue locks #760

Open
opened 2026-07-19 11:51:08 -05:00 by jcwalker3 · 0 comments
Owner

Summary

An expired file-store author issue lock cannot be renewed by its exact owner when the recorded PID remains alive and the worktree still exists.

Because the PID represents a persistent MCP daemon rather than the individual author task, daemon liveness is not proof that task ownership remains active. The expired-lock path treats same-owner continuation as a foreign takeover and returns before exact ownership is considered.

Verified against master bde5c5fb20fbf6aec9cd6b802341c43078921d13.

Observed production state

Issue #757 was a historical instance of the expired-plus-live-daemon condition. It is not reproduced live at the time of filing, and this issue does not claim that it is.

Durable lock record for #757, read from ~/.cache/gitea-tools/issue-locks/prgs-Scaled-Tech-Consulting-Gitea-Tools-757.json:

issue_number      757
branch_name       fix/issue-757-create-issue-bootstrap-anti-stomp
worktree_path     branches/issue-757-create-issue-bootstrap-anti-stomp
operation_type    author_issue_work
claimant          jcwalker3 / prgs-author
pid, session_pid  3543
created_at        2026-07-19T06:11:51Z
expires_at        2026-07-19T10:11:51Z
last_heartbeat_at 2026-07-19T06:11:51Z

Corroborating repository state, all verified:

  • registered worktree present and clean;
  • local branch head, remote branch head, and open PR #759 head all adc61255b2d7eb65d1670e09e561dbd84b652317;
  • last_heartbeat_at equals created_at, so the lease was never renewed across a multi-hour authoring task;
  • the control-plane lease database holds no row for issue 757, confirming the lock exists only in the file store.

Why this instance no longer demonstrates the defect

Recorded PID 3543 has since exited. kill -0 3543 reports no such process, and the live MCP daemon cohorts carry unrelated PIDs.

With a dead PID the reclaim gate now permits recovery: dead becomes true, the fail-closed return is skipped, assess_expired_lock_reclaim returns reclaim_allowed=true, and assess_same_issue_lease_conflict returns None. Issue #757 is therefore recoverable today through the existing dead-PID path and is filed here as evidence of the condition, not as a live reproduction.

What remains proven

The expired-plus-live-PID same-owner renewal gap is proven by production code inspection and by the absence of any test covering it. Nothing in the source grants renewal to an exact owner whose lease expired while its recording daemon stayed alive, and no regression asserts that behavior in either direction. Because the recorded PID is the long-lived MCP daemon, a live PID at expiry is the ordinary case for any author task that outlives the four-hour TTL — the condition will recur.

Root cause

Gate ordering

assess_same_issue_lease_conflict (issue_lock_store.py:436) computes same-owner evidence before it consults it:

  • issue_lock_store.py:461-464same_owner is computed from the locked branch and the realpath-normalized worktree.
  • issue_lock_store.py:465 — the expired-lease branch is entered.
  • issue_lock_store.py:466-470 — on reclaim_allowed, returns None.
  • issue_lock_store.py:471-475 — otherwise returns the "Recovery review is required before takeover (fail closed)" error.
  • issue_lock_store.py:476 — the same_owner allowance. Unreachable whenever the lease is expired, because both preceding paths return.

assess_expired_lock_reclaim (issue_lock_store.py:379) permits reclaim only on dead PID or missing worktree (issue_lock_store.py:404-420, gate at :410). An exact owner whose daemon is alive and whose worktree is present satisfies neither, so it takes the fail-closed return and never reaches line 476.

Why the lease expires under an active task

session_pid and pid are bound from os.getpid() at issue_lock_store.py:170-171 and :174, recording the persistent MCP daemon rather than the individual author task. The session pointer path also embeds that PID (issue_lock_store.py:73).

Expiry is computed once at creation — gitea_mcp_server.py:2016-2027, with WORK_LEASE_TTL_HOURS = 4 at gitea_mcp_server.py:1913 — from the single call site at gitea_mcp_server.py:3398. No renewal writer exists for this store. gitea_post_heartbeat (gitea_mcp_server.py:15481) does not reference the file store, work_lease, or expires_at. gitea_heartbeat_reviewer_pr_lease (gitea_mcp_server.py:12297) renews a reviewer PR comment lease, a different substrate. Once wall-clock passes expires_at, assess_lock_freshness (issue_lock_store.py:340-355) reports the lock non-live no matter how active the owning task is.

Two distinct substrates

File-store author locks are JSON on disk (issue_lock_store.py:34, :59, :117, :129). Control-plane workflow leases are SQLite (control_plane_db.py:23, :44, :254; lease_lifecycle.py:31).

gitea_adopt_workflow_lease (gitea_mcp_server.py:18313), gitea_release_workflow_lease (:18368), and gitea_reclaim_expired_workflow_lease (:18488) all delegate to lease_lifecycle, which never imports issue_lock_store. Those tools cannot clear, refresh, or overwrite a file-store issue lock.

The only writer is blocked

gitea_lock_issue is the sole native writer of the file store: gitea_mcp_server.py:3428 calls _save_issue_lock (:1982, :1993), which calls bind_session_lock, which performs the writes at issue_lock_store.py:197-198. It is refused by the expired conflict at two points — the pre-check at gitea_mcp_server.py:3215-3223 and again under the exclusive file lock at issue_lock_store.py:189-196 — before reaching the write.

Downstream, verify_lock_for_mutation (issue_lock_store.py:565, call site gitea_mcp_server.py:3628) requires a live lock at issue_lock_store.py:577-579, so no author mutation can proceed.

Related but separately owned

assess_lock_freshness (issue_lock_store.py:340-355) has two independent non-live triggers: expired-by-time and dead-PID. Line 465 gates the reclaim assessor on expiry alone, so the two triggers have symmetric but opposite gaps. The dead-PID-under-unexpired-lease side is documented at issue_lock_recovery.py:12-14 and is owned by #753; it is not re-owned here.

Verify current line numbers during implementation rather than treating reported locations as immutable.

Safety impact

  • Valid author work becomes permanently unmodifiable through sanctioned tools once its lease expires under a live daemon.
  • Request-changes remediation cannot proceed, because the author cannot re-acquire the lock its own open PR depends on.
  • Restarting a daemon or hand-editing the lock JSON becomes a tempting false workaround; both launder false evidence through a guard.
  • Long-running daemons make PID liveness unsuitable as task-liveness evidence — a live PID proves only that the server is up, not that the authoring task still holds the work.
  • Automation stalls even though exact ownership evidence remains fully intact and independently verifiable.

Acceptance criteria

AC1. Distinguish exact-owner renewal from foreign takeover.

AC2. Evaluate a strengthened same-owner renewal disposition before the expired foreign-takeover return.

AC3. Renewal requires exact match of: remote; owner; repository; issue number; operation type; branch; realpath-normalized worktree; claimant username; claimant profile.

AC4. The registered worktree must exist and be clean.

AC5. Local branch head must equal the remote branch head.

AC6. When an owning PR exists, local head, remote branch head, and live PR head must all match.

AC7. No competing live lock, competing branch claim, or other owning PR may exist.

AC8. Any missing or contradictory evidence fails closed.

AC9. Successful renewal atomically records: prior PID; replacement PID; prior expiry; renewal timestamp; new expiry; claimant and evidence provenance.

AC10. The renewed lock must immediately satisfy the existing downstream verify_lock_for_mutation path.

AC11. Foreign expired-lock takeover continues to require the existing dead-PID or missing-worktree recovery conditions.

AC12. A live foreign lease remains non-recoverable.

AC13. Renewal is available through a native sanctioned tool path; no JSON edit, daemon restart, fabricated worktree, or direct API/CLI mutation is required.

AC14. Do not expose a caller-controlled boolean that declares ownership or renewal eligibility.

AC15. Tests cover: expired plus alive PID plus present clean worktree plus exact owner, renewal allowed; different branch refused; different worktree refused; different claimant or profile refused; dirty worktree refused; local and remote head mismatch refused; PR-head mismatch refused; competing lock, branch, or PR refused; live foreign lease refused; existing dead-PID reclaim unchanged; renewed lock satisfies downstream mutation ownership.

AC16. Add an explicit regression proving an MCP daemon PID remaining alive is not sufficient evidence of an active author task after lease expiry.

AC17. Do not special-case #757, PR #759, or any repository issue number.

Validation expectations

  • Focused unit tests for the renewal disposition covering each rejection reason independently.
  • An MCP-level regression through native gitea_lock_issue, not only against the assessor functions.
  • A test asserting line-476-equivalent reachability for the exact-owner expired case, so the ordering defect cannot silently return.
  • The relevant full-suite baseline comparison, reported against known pre-existing master failures rather than as an absolute pass count.

Linkage and ownership boundary

  • #753 — closed; dead PID under an unexpired lease. Opposite trigger of the same freshness assessor. Do not reopen or re-own.
  • #755 — closed; owning-PR duplicate gate during dead-session recovery. Different gate.
  • #601 — closed; introduced the lease lifecycle and assess_expired_lock_reclaim. The component being corrected.
  • #757, PR #759 — evidence only. Do not modify their scope, labels, or review state as part of this work.

Canonical issue state

STATE: ready-for-author
WHO_IS_NEXT: author
NEXT_ACTION: Lock this issue in a separately bounded author task, then implement AC1-AC17
NEXT_PROMPT: Author the exact-owner expired-lock renewal disposition; PR; stop
## Summary An expired file-store author issue lock cannot be renewed by its exact owner when the recorded PID remains alive and the worktree still exists. Because the PID represents a persistent MCP daemon rather than the individual author task, daemon liveness is not proof that task ownership remains active. The expired-lock path treats same-owner continuation as a foreign takeover and returns before exact ownership is considered. Verified against master `bde5c5fb20fbf6aec9cd6b802341c43078921d13`. ## Observed production state Issue #757 was a historical instance of the expired-plus-live-daemon condition. It is **not** reproduced live at the time of filing, and this issue does not claim that it is. Durable lock record for #757, read from `~/.cache/gitea-tools/issue-locks/prgs-Scaled-Tech-Consulting-Gitea-Tools-757.json`: ```text issue_number 757 branch_name fix/issue-757-create-issue-bootstrap-anti-stomp worktree_path branches/issue-757-create-issue-bootstrap-anti-stomp operation_type author_issue_work claimant jcwalker3 / prgs-author pid, session_pid 3543 created_at 2026-07-19T06:11:51Z expires_at 2026-07-19T10:11:51Z last_heartbeat_at 2026-07-19T06:11:51Z ``` Corroborating repository state, all verified: * registered worktree present and clean; * local branch head, remote branch head, and open PR #759 head all `adc61255b2d7eb65d1670e09e561dbd84b652317`; * `last_heartbeat_at` equals `created_at`, so the lease was never renewed across a multi-hour authoring task; * the control-plane lease database holds **no** row for issue 757, confirming the lock exists only in the file store. ### Why this instance no longer demonstrates the defect Recorded PID `3543` has since exited. `kill -0 3543` reports no such process, and the live MCP daemon cohorts carry unrelated PIDs. With a dead PID the reclaim gate now permits recovery: `dead` becomes true, the fail-closed return is skipped, `assess_expired_lock_reclaim` returns `reclaim_allowed=true`, and `assess_same_issue_lease_conflict` returns `None`. Issue #757 is therefore recoverable today through the existing dead-PID path and is filed here as evidence of the condition, not as a live reproduction. ### What remains proven The expired-plus-live-PID same-owner renewal gap is proven by production code inspection and by the absence of any test covering it. Nothing in the source grants renewal to an exact owner whose lease expired while its recording daemon stayed alive, and no regression asserts that behavior in either direction. Because the recorded PID is the long-lived MCP daemon, a live PID at expiry is the ordinary case for any author task that outlives the four-hour TTL — the condition will recur. ## Root cause ### Gate ordering `assess_same_issue_lease_conflict` (`issue_lock_store.py:436`) computes same-owner evidence before it consults it: * `issue_lock_store.py:461-464` — `same_owner` is computed from the locked branch and the realpath-normalized worktree. * `issue_lock_store.py:465` — the expired-lease branch is entered. * `issue_lock_store.py:466-470` — on `reclaim_allowed`, returns `None`. * `issue_lock_store.py:471-475` — otherwise returns the "Recovery review is required before takeover (fail closed)" error. * `issue_lock_store.py:476` — the `same_owner` allowance. **Unreachable whenever the lease is expired**, because both preceding paths return. `assess_expired_lock_reclaim` (`issue_lock_store.py:379`) permits reclaim only on dead PID or missing worktree (`issue_lock_store.py:404-420`, gate at `:410`). An exact owner whose daemon is alive and whose worktree is present satisfies neither, so it takes the fail-closed return and never reaches line 476. ### Why the lease expires under an active task `session_pid` and `pid` are bound from `os.getpid()` at `issue_lock_store.py:170-171` and `:174`, recording the persistent MCP daemon rather than the individual author task. The session pointer path also embeds that PID (`issue_lock_store.py:73`). Expiry is computed once at creation — `gitea_mcp_server.py:2016-2027`, with `WORK_LEASE_TTL_HOURS = 4` at `gitea_mcp_server.py:1913` — from the single call site at `gitea_mcp_server.py:3398`. No renewal writer exists for this store. `gitea_post_heartbeat` (`gitea_mcp_server.py:15481`) does not reference the file store, `work_lease`, or `expires_at`. `gitea_heartbeat_reviewer_pr_lease` (`gitea_mcp_server.py:12297`) renews a reviewer PR comment lease, a different substrate. Once wall-clock passes `expires_at`, `assess_lock_freshness` (`issue_lock_store.py:340-355`) reports the lock non-live no matter how active the owning task is. ### Two distinct substrates File-store author locks are JSON on disk (`issue_lock_store.py:34`, `:59`, `:117`, `:129`). Control-plane workflow leases are SQLite (`control_plane_db.py:23`, `:44`, `:254`; `lease_lifecycle.py:31`). `gitea_adopt_workflow_lease` (`gitea_mcp_server.py:18313`), `gitea_release_workflow_lease` (`:18368`), and `gitea_reclaim_expired_workflow_lease` (`:18488`) all delegate to `lease_lifecycle`, which never imports `issue_lock_store`. Those tools cannot clear, refresh, or overwrite a file-store issue lock. ### The only writer is blocked `gitea_lock_issue` is the sole native writer of the file store: `gitea_mcp_server.py:3428` calls `_save_issue_lock` (`:1982`, `:1993`), which calls `bind_session_lock`, which performs the writes at `issue_lock_store.py:197-198`. It is refused by the expired conflict at two points — the pre-check at `gitea_mcp_server.py:3215-3223` and again under the exclusive file lock at `issue_lock_store.py:189-196` — before reaching the write. Downstream, `verify_lock_for_mutation` (`issue_lock_store.py:565`, call site `gitea_mcp_server.py:3628`) requires a live lock at `issue_lock_store.py:577-579`, so no author mutation can proceed. ### Related but separately owned `assess_lock_freshness` (`issue_lock_store.py:340-355`) has two independent non-live triggers: expired-by-time and dead-PID. Line 465 gates the reclaim assessor on expiry alone, so the two triggers have symmetric but opposite gaps. The dead-PID-under-unexpired-lease side is documented at `issue_lock_recovery.py:12-14` and is owned by #753; it is not re-owned here. Verify current line numbers during implementation rather than treating reported locations as immutable. ## Safety impact * Valid author work becomes permanently unmodifiable through sanctioned tools once its lease expires under a live daemon. * Request-changes remediation cannot proceed, because the author cannot re-acquire the lock its own open PR depends on. * Restarting a daemon or hand-editing the lock JSON becomes a tempting false workaround; both launder false evidence through a guard. * Long-running daemons make PID liveness unsuitable as task-liveness evidence — a live PID proves only that the server is up, not that the authoring task still holds the work. * Automation stalls even though exact ownership evidence remains fully intact and independently verifiable. ## Acceptance criteria **AC1.** Distinguish exact-owner renewal from foreign takeover. **AC2.** Evaluate a strengthened same-owner renewal disposition before the expired foreign-takeover return. **AC3.** Renewal requires exact match of: remote; owner; repository; issue number; operation type; branch; realpath-normalized worktree; claimant username; claimant profile. **AC4.** The registered worktree must exist and be clean. **AC5.** Local branch head must equal the remote branch head. **AC6.** When an owning PR exists, local head, remote branch head, and live PR head must all match. **AC7.** No competing live lock, competing branch claim, or other owning PR may exist. **AC8.** Any missing or contradictory evidence fails closed. **AC9.** Successful renewal atomically records: prior PID; replacement PID; prior expiry; renewal timestamp; new expiry; claimant and evidence provenance. **AC10.** The renewed lock must immediately satisfy the existing downstream `verify_lock_for_mutation` path. **AC11.** Foreign expired-lock takeover continues to require the existing dead-PID or missing-worktree recovery conditions. **AC12.** A live foreign lease remains non-recoverable. **AC13.** Renewal is available through a native sanctioned tool path; no JSON edit, daemon restart, fabricated worktree, or direct API/CLI mutation is required. **AC14.** Do not expose a caller-controlled boolean that declares ownership or renewal eligibility. **AC15.** Tests cover: expired plus alive PID plus present clean worktree plus exact owner, renewal allowed; different branch refused; different worktree refused; different claimant or profile refused; dirty worktree refused; local and remote head mismatch refused; PR-head mismatch refused; competing lock, branch, or PR refused; live foreign lease refused; existing dead-PID reclaim unchanged; renewed lock satisfies downstream mutation ownership. **AC16.** Add an explicit regression proving an MCP daemon PID remaining alive is not sufficient evidence of an active author task after lease expiry. **AC17.** Do not special-case #757, PR #759, or any repository issue number. ## Validation expectations * Focused unit tests for the renewal disposition covering each rejection reason independently. * An MCP-level regression through native `gitea_lock_issue`, not only against the assessor functions. * A test asserting line-476-equivalent reachability for the exact-owner expired case, so the ordering defect cannot silently return. * The relevant full-suite baseline comparison, reported against known pre-existing master failures rather than as an absolute pass count. ## Linkage and ownership boundary * **#753** — closed; dead PID under an unexpired lease. Opposite trigger of the same freshness assessor. Do not reopen or re-own. * **#755** — closed; owning-PR duplicate gate during dead-session recovery. Different gate. * **#601** — closed; introduced the lease lifecycle and `assess_expired_lock_reclaim`. The component being corrected. * **#757**, **PR #759** — evidence only. Do not modify their scope, labels, or review state as part of this work. ## Canonical issue state ```text STATE: ready-for-author WHO_IS_NEXT: author NEXT_ACTION: Lock this issue in a separately bounded author task, then implement AC1-AC17 NEXT_PROMPT: Author the exact-owner expired-lock renewal disposition; PR; stop ```
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Scaled-Tech-Consulting/Gitea-Tools#760