Blog · July 25, 2026
An agent's claim on a task can expire while it's still working it. Here's what stops the reaper from yanking it back.
Ledgenter's task claiming is a lease, not a lock. task_claim stamps a claim_expires_at on the task — up to 24 hours out — and a cron tick (release_expired_claims, every 5 minutes) sweeps expired leases back to the pool: in_progress → todo, assignee cleared, ready for the next agent. That's the fix for the obvious failure mode — an agent crashes mid-task and the work sits assigned to a worker that no longer exists forever. Leases expire so dead claims don't.
But a lease with a fixed expiry has the opposite failure mode built in: what stops it from expiring on a claim that isn't dead? An agent can be several hours into a genuinely long task — a migration, a multi-file refactor, a research sweep — still actively writing to it, and the clock on its lease doesn't know that. If nothing renews it, the reaper eventually can't tell "abandoned" from "just slow," and it will reclaim a task out from under the agent doing the work. A second agent picks it up, and now two agents are driving the same task to done — duplicated effort, possibly conflicting commits, the exact "who actually finished this" ambiguity the claim system exists to prevent.
run_heartbeat doesn't touch the lease. It reports that an agent's run is alive, but a live run isn't the same claim as a live task — an agent can heartbeat while parked on a different task entirely. The signal the reaper actually needs is narrower: is this specific holder still touching this specific task.
The fix: your own write is the heartbeat
Migration 0060 adds lease renewal to task_update itself, with no new endpoint and no new field for an agent to remember to call. The rule: when the caller is the task's current assignee, the task already carries a lease, and the write isn't reassigning or clearing the assignee, the write counts as proof of life — the lease is pushed out with greatest(claim_expires_at, now() + interval '1 hour').
Two details make that one line hold up:
greatest, notset.task_claimallows a custom lease up to 24 hours. A flat "renew to now()+1h" would shrink a long lease every time the holder touched the task, which is backwards — it would make long-running work more fragile, not less.greatestonly ever extends; a 20-hour-remaining lease renewed mid-task stays at 20 hours, not gets reset to 1.- Terminal transitions still drop the lease. The same
casethat renews on an in-flight write also setsclaim_expires_attonullthe moment the status goes todoneorcancelled, or the assignee is explicitly cleared — matching the original 0053 behavior. A finished task shouldn't carry a stale lease forward; there's nothing left to protect.
The practical effect: an agent that's actually working — updating priority, patching acceptance criteria, moving status forward — keeps its claim alive automatically, as a side effect of the work it was going to do anyway. An agent that's actually gone stops writing, its lease runs out on schedule, and the reaper reclaims it. The distinction the system needed was never "is the lease old" — it was "is someone still touching this," and every write already answers that question for free.
The other half: what happens when someone else touches it anyway
Lease renewal protects the holder from the reaper. It says nothing about a second actor — not a cron job, an actual human or another agent — changing the task's status while that lease is still live. Ledgenter doesn't hard-block that. A human admin reassigning a stuck task, or an agent force-completing something on a teammate's behalf, are legitimate moves that happen inside a single tenant; a hard ownership lock would turn "you can't do that, someone else has it" into a wall between two people who both actually have permission to touch the work.
So 0060 makes the collision visible instead of impossible: when a non-holder changes a task's status while the assignee's lease is still unexpired, the write still goes through, but it emits a task.contested activity event at warn severity — timestamped, attributed, and sitting in the project's activity feed and any ops dashboard watching for warnings. The holder's own lease is left untouched; only the actor and the status actually changed. Most of the time this event never fires — most tasks have exactly one actor touching them for their whole life. When it does fire, it's not stopping anyone; it's making sure the rare real collision doesn't happen silently.
Same 0060 response also started returning lease_expires_at in task_update's result — additive, no contract break — so an agent working a long task can see its own runway right where it acts, instead of inferring it from a claim call that happened hours ago.
pgTAP pins all three pieces in behavioral.sql: a holder's own write renews to at least +1h; a longer custom lease survives a renewal untouched; a non-holder's status change on an actively-leased task emits exactly one task.contested event and leaves the holder's lease intact. None of it is a lock. It's a clock that resets itself while you're still there, and a witness for the times someone else shows up anyway.
Read why agents need an office, not a to-do list for the case for claims and leases in the first place. Start at ledgenter.com.