Table
NAME
MCP::Client::Leases::Table - the in-process lease book two agents share
SYNOPSIS
use MCP::Client::Leases::Table;
my $table = MCP::Client::Leases::Table.new(default-ttl => 300);
my %got = $table.acquire(holder => 'writer-1', paths => ['/srv/src', '/srv/README.md']);
say %got<granted>; # True
my %no = $table.acquire(holder => 'reviewer-1', paths => ['/srv/src/app.raku']);
say %no<granted>; # False
say %no<conflicts>[0]<holder>; # writer-1
say %no<conflicts>[0]<held-for>.Int; # 3
say $table.covering(holder => 'writer-1', path => '/srv/src/app.raku')<verdict>; # own
say $table.covering(holder => 'reviewer-1', path => '/srv/src/app.raku')<verdict>; # other
$table.release(holder => 'writer-1'); # bare: everything this holder has
DESCRIPTION
The state behind MCP::Client::Leases: which agent has claimed which part of the workspace, and until when. It is a plain object with a lock, owned by whatever owns the agents ā one table per shared workspace, shared by every composer that fronts an agent working in it.
Everything it answers is plain data, and contention is a value rather
than an exception: acquire either grants, or comes back saying who is in the
way and for how long they have been there. That is the whole reason the lease
layer above it can promise never to throw from a tool call.
A lease, and what it covers
A lease is one path ā a file or a directory ā claimed exclusively by one holder for a while:
{
path => '/srv/src', # as the caller gave it, already absolutized
holder => 'writer-1', # an agent id
acquired-at => Instant,
ttl => 300, # seconds
expires-at => Instant,
held-for => 12.4, # seconds, as at the moment of the snapshot
expires-in => 287.6,
}
A lease on a directory covers everything under it, by the same lexical,
segment-wise containment test the permission engine uses
(MCP::Client::Policy::Rules's path-under). Nothing here touches a
filesystem: the paths belong to the server's filesystem, which may not be
this machine's at all.
Containment is checked in both directions when a lease is taken. Claiming
/srv/src collides with somebody else's lease on /srv/src/app.raku just as
surely as the other way round, and an unknown in either direction (a ..
segment, a backslash, an absolute path measured against a relative one) counts
as a collision. Fail-closed: a path the table cannot reason about is a path it
will not hand out.
All or nothing
acquire grants every path it was asked for, or none of them. Partial grants
are how two agents each holding half of what they need come to sit and wait for
each other; refusing the whole request means a caller is never left holding
something it cannot use. wait-attempt adds an atomic waiter registry for a
composer that chooses bounded waiting: it records blocker edges, rejects cycles
of any length, and never sleeps under the Table lock. The standard composer
also refuses hold-and-wait entirely.
Expiry is lazy
Every operation reaps expired leases first, inside the lock, before it looks at
anything. There is no timer thread and nothing to shut down: a table nobody
calls is a table doing nothing, and a wedged holder's claim evaporates the
moment somebody else asks about it. default-ttl is 300 seconds; a per-call
ttl overrides it, and a holder re-acquiring a path it already holds refreshes
the clock rather than stacking a second lease.
Thread safety
One lock, held only across state transitions and never while calling anybody
else's code. Readers get deep copies. status returns leases, waiter edges and
in-flight mutation pins from one instant. A pin keeps its exact lease generation
alive while provider code runs; expiry or release is applied after unpin.
EXAMPLES
The contention answer is the interesting one, because it is what a model is told. Everything a refusal needs to be actionable is in it ā who, what, and how long they have had it:
my %no = $table.acquire(holder => 'reviewer-1', paths => ['/srv/src/app.raku']);
unless %no<granted> {
for %no<conflicts>.list -> %clash {
say "{%clash<path>} is inside {%clash<leased>}, held by {%clash<holder>} "
~ "for {%clash<held-for>.Int}s";
}
}
A holder that wedges is handled by the TTL, and one that finishes is handled by whoever is watching it. Both are one call:
# The agent's run finished (drained, not merely answered): tidy up after it.
$table.release-holder('writer-1');
# Engine shutdown.
$table.release-all;
Re-acquiring is how a long edit keeps its claim alive without a second kind of call ā the TTL restarts, and no second lease appears:
$table.acquire(holder => 'writer-1', paths => ['/srv/src'], ttl => 30);
# ... 25 seconds of work later ...
$table.acquire(holder => 'writer-1', paths => ['/srv/src'], ttl => 30);
say $table.leases.elems; # 1
SEE ALSO
MCP::Client::Leases ā the provider that
publishes lock_acquire/lock_release over this table and enforces it on
mutating calls.