Table of Contents

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

  1. A claim is granted with a fencing token and a bounded lease.
  2. While the claim is live, an unfenced write is refused - the claim is not advisory.
  3. A second worker claiming after the lease lapses gets a strictly higher token.
  4. 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.
  5. 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.

See also