AgentBacklog - claiming, fencing, and releasing backlog work
This page is part of the documentation for Orleans.Lattice 9.9.0 (release line 9.9), built 2026-10-04. It is also published as markdown, with every table and list, at README.md, and llms.txt lists every page.A runnable walkthrough of the claim and lease surface that makes an agent-operated backlog safe for several agents to drain at once.
Where the DistributedLock sample shows the lock primitive in isolation, this one shows what it buys you once it guards a shared, concurrently-drained work queue: a claim that expires instead of stranding an item, and a fence that a superseded worker cannot write through.
The claim surface is exposed as Model Context Protocol tools rather than a public
C# API, so this sample drives it the way an agent does - by calling
repocontext_* tools against a running host. It reuses the
RepoContextContainer box rather than
standing up a second one.
Background on the design is in The agent-operated backlog.
To adopt the backlog in your own repository, the copyable base protocol and
agent definitions are in template/. This repository
consumes them unmodified, through two small binding overrides.
What you will observe
- A claim is granted with a fencing token and a bounded lease.
- While the claim is live, an unfenced write is refused - the claim is not advisory.
- A second worker claiming after the lease lapses gets a strictly higher token.
- The first worker, now superseded, is refused even though it still holds its token and still believes it owns the item. This is the case a boolean "claimed" flag cannot handle.
- Releasing readmits ordinary unfenced writes.
Prerequisites
Start the container box exactly as in the
RepoContextContainer sample: copy its
.env.example to .env and set REPO_PATH and
REPOCONTEXT_MEMORY_ARCHIVE_PATH in it first, because compose refuses every
command in that directory until the archive path is set.
cd ../RepoContextContainer
cp .env.example .env # then edit REPO_PATH and REPOCONTEXT_MEMORY_ARCHIVE_PATH
docker compose up -d
The host must be running with writes enabled (AddRepoContextTools(enableWrites: true)),
which the container sample's default configuration does. Without it the mutating
tools are not contributed at all, and you will not see them in the tool list -
that is the fail-closed gate working, not a fault.
Every step below is a tool call. Issue them from any MCP client pointed at the host, or from the agent session you are running.
1. Create an item and claim it
Backlog items are ordinary memory records, so the item is created with
repocontext_remember. The id is deterministic and derived from the mirrored
issue number - never a generated GUID - so a retry merges in place.
repocontext_remember(
repoId: "lattice", topic: "backlog", id: "issue-2101",
kind: "Note", author: "backlog-pm",
title: "Batch the shipper poll against the new seam",
body: "Spec: https://github.com/NSTA1/Orleans.Lattice/issues/2101",
tags: ["backlog", "priority:P1", "phase:implementation",
"homeRegion:local", "baseBranch:feat/epic/wal-batching"])
repocontext_claim(key: "repo/lattice/mem/backlog/issue-2101",
owner: "worker-a", leaseSeconds: 60)
The claim returns granted: true with a fencingToken and a
leaseExpiresAtUtc. Note what it does not do on contention: a claim that
loses a race returns granted: false with a reason of contended, timeout, or
missing. Losing is an ordinary outcome, so a worker branches on the result
rather than catching an exception.
2. The claim excludes unfenced writes
With the claim live, write the resume block the way a careless caller would - without presenting the token:
repocontext_update(key: "repo/lattice/mem/backlog/issue-2101",
fields: { "body": "started" })
This is refused with RepoContextClaimConflictException. That refusal is the
whole point: an item's body is a last-writer-wins register, and it is only safe
to hold a resume block there because, while the claim is live, the fence admits
only a write that presents the claim's current token.
Present the token and the same write succeeds:
repocontext_update(key: "repo/lattice/mem/backlog/issue-2101",
fields: { "body": "branch feat/epic/wal-batching-issue-2101, seam wired, tests pending" },
fencingToken: <token from step 1>)
3. Let the lease lapse and claim as a second worker
Wait out leaseSeconds without calling repocontext_renew_claim, simulating a
worker whose session was stopped. This is the normal path, not an exceptional
one: scheduled agent sessions are stopped mid-flight often enough that a claim
must never be a flag a dead session leaves set.
repocontext_claim(key: "repo/lattice/mem/backlog/issue-2101",
owner: "worker-b", leaseSeconds: 60)
Granted, with a strictly higher fencing token. The lock never reuses a token, across activations or crashes.
4. The superseded worker is refused
Now have the first worker do what a partitioned or paused process does - carry on as though nothing happened:
repocontext_update(key: "repo/lattice/mem/backlog/issue-2101",
fields: { "body": "worker-a still thinks it owns this" },
fencingToken: <token from step 1>)
Refused. The record keeps its fence as a high-water mark, and a token below it can never write again - not by a direct write, and not by a concurrent replica merge, because the mark is stored keyed by the token itself and so behaves as a maximum.
worker-a can also discover this without guessing, by renewing:
repocontext_renew_claim(key: "repo/lattice/mem/backlog/issue-2101",
fencingToken: <token from step 1>, leaseSeconds: 60)
A reason of superseded is the authoritative "you have lost this item" signal. A
worker seeing it abandons immediately and writes nothing further.
5. Release
repocontext_release_claim(key: "repo/lattice/mem/backlog/issue-2101",
fencingToken: <token from step 3>)
Release is idempotent, and it readmits ordinary unfenced writes. It only raises the released marker; it never lowers the fence, so a superseded holder stays superseded after the item is free again.
A note on repocontext_claim_status
repocontext_claim_status reports the live claim, the queue depth, and the lease
expiry - and its authoritative property is hard-wired to false. That is
deliberate: a status read is a snapshot that can be stale by the time you act on
it, so no call site can project an authoritative decision from it. Use it to
observe and report. Only a granted claim or a renew verdict is authoritative.
What this does not show
Region scoping. Claims are region-scoped, so a claim taken in one region refuses
a write from another, which is why an item carries a homeRegion: tag. Observing
it needs a multi-region deployment; see
Cross-cluster replication.