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:
- "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.
- 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
The gov-plugin
pre-edithook 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%':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:
released_at IS NULLat an instant returns 2–4 and looks trivial; it is mostly counting leases that are legitimately held right now. The real quantity isreleased_at >= expires_at— released by the reaper, not by the holder. That is 99 today, not 3.expires_atout, making the TTL decorative. These have a fixedexpires_atand 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 areintent='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, butpre-editstill blocks becausereleased_atis 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 pollreleased_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
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.pre-edittreatexpires_at < now()as clear rather than waiting forreleased_at.Reproduce
Edit the same file twice within 5 minutes via the plugin
pre-edithook path. Detection SQL for the real rate:https://claude.ai/code/session_01NRQwpPKm8CMYeiQecvvdjd