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.

MCP::Client v0.5.0

talk to an MCP server, in either protocol era

Authors

  • Matt Doughty

License

Artistic-2.0

Dependencies

MCP::Server:ver<0.6.0+>:auth<zef:apogee>JSON::Fast:ver<0.19+>:auth<cpan:TIMOTIMO>Cro::HTTP:ver<0.8.11+>:auth<zef:cro>:api<0>MIME::Base64:ver<1.2.5+>:auth<zef:raku-community-modules>

Test Dependencies

Provides

  • MCP::Client
  • MCP::Client::Cache
  • MCP::Client::Correlator
  • MCP::Client::Exceptions
  • MCP::Client::Leases
  • MCP::Client::Leases::Table
  • MCP::Client::Policy
  • MCP::Client::Policy::Commands
  • MCP::Client::Policy::Floor
  • MCP::Client::Policy::Grants
  • MCP::Client::Policy::Rules
  • MCP::Client::Protocol
  • MCP::Client::Reasons
  • MCP::Client::Registry
  • MCP::Client::SSE
  • MCP::Client::Transport
  • MCP::Client::Transport::HTTP
  • MCP::Client::Transport::Stdio
  • MCP::Client::UnknownKeys

The Camelia image is copyright 2009 by Larry Wall. "Raku" is a trademark of the Yet Another Society. All rights reserved.

Built with Podlite — the markup and publishing tools behind this site.