Skip to content

plugin pre-edit leases: 20% never released by the holder (0% on 07-27), so sequential edits to one file self-block for the full 5min TTL #1446

Description

@cirwel

The gov-plugin pre-edit hook acquires a file lease and, increasingly often, never releases it — the lease is instead reaped when its 5-minute TTL expires. Any second edit to the same file inside that window is blocked. I hit this six times in one task today.

The rate, and when it started

lease_plane.surface_leases WHERE intent LIKE '%plugin%':

day leases never released (reaped at TTL) % holds >5s max hold
2026-07-26 58 0 0% 8 293s
2026-07-27 73 0 0% 3 176s
2026-07-28 92 2 2% 5 327s
2026-07-29 113 3 3% 5 324s
2026-07-30 243 22 9% 29 329s
2026-07-31 490 99 20% 117 708s

Median release latency over the same window: 0.118 → 0.119 → 0.121 → 0.122 → 0.174 → 0.399 → 0.721s. Normal release is ~120ms; it is now ~720ms at the median with a long tail.

Onset is 2026-07-28, not today. Two prior days sit at exactly zero.

Corrections to two readings I made while diagnosing this

Recording both so nobody re-derives them:

  1. "N leases are currently orphaned" is the wrong measurement. Sampling released_at IS NULL at an instant returns 2–4 and looks trivial; it is mostly counting leases that are legitimately held right now. The real quantity is released_at >= expires_at — released by the reaper, not by the holder. That is 99 today, not 3.
  2. This is NOT the immortal-lease shape from 2026-07-30. Those were unreapable because a client timeout plus server auto-renew kept pushing expires_at out, making the TTL decorative. These have a fixed expires_at and do expire — the failure is a missing release. The 07-30 detection SQL will not find them.

I also floated _schedule_presence_refresh (#1424, deployed today) as a possible cause. That is wrong on timing — the onset is 07-28, three days earlier — and wrong on producer: these are intent='claude plugin Edit'/'claude plugin Write' leases from the gov-plugin hook, not the server's presence path.

What the evidence actually supports

Volume grew 58 → 490/day (8.4×) over the same window, so load is clearly involved. But the failure rate grew 0% → 20%, i.e. faster than volume — so it is not purely proportional. The holder PIDs are short-lived hook subprocesses; every one I checked had already exited while its lease was still held, which is consistent with the release call not completing before the process goes away.

That points at the release path being racy under concurrency rather than a specific regression, but I have not read the hook's release code and am not asserting a mechanism.

Second, smaller defect: the reaper lags expiry

Observed twice: a lease reads expired by expires_at, but pre-edit still blocks because released_at is still NULL. The hook's own message acknowledges this state ("expiring now — should clear within a reaper cycle"). Max observed hold is 708s against a 300s TTL, so the lag can be minutes, not seconds.

Practical effect: polling on expires_at > now() reports clear while the hook still refuses. Anything waiting on a lease must poll released_at IS NOT NULL.

Why it is worth fixing rather than tolerating

Two sequential edits to one file are ordinary — a code change plus its test, or a fix plus a follow-up correction. At a 20% miss rate each one risks a 5-minute stall, and the stall is silent until you attempt the second edit. In one task today it turned roughly 6 edits into ~25 minutes of waiting.

The lease exists to stop two different agents colliding. In every case I hit, the blocking holder was a dead subprocess from my own prior edit — so the cost was paid with no safety benefit.

Suggested direction

  • Release in a finally (or equivalent) in the hook so process exit cannot skip it, and/or make acquisition idempotent per (surface, holder) so the same editor re-acquires instead of blocking.
  • Consider a much shorter TTL for edit leases: the operation is sub-second, so a 300s TTL is ~2500× the work it guards, and every missed release costs the full TTL.
  • Have the reaper run on a tighter cycle, or have pre-edit treat expires_at < now() as clear rather than waiting for released_at.

Reproduce

Edit the same file twice within 5 minutes via the plugin pre-edit hook path. Detection SQL for the real rate:

SELECT acquired_at::date, count(*),
       count(*) FILTER (WHERE released_at >= expires_at) AS never_released_by_holder
FROM lease_plane.surface_leases
WHERE intent LIKE '%plugin%'
GROUP BY 1 ORDER BY 1 DESC;

https://claude.ai/code/session_01NRQwpPKm8CMYeiQecvvdjd

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions