Skip to content

mcp: stop a land for overlaps the agent has not seen (pre-flight) - #282

Merged
BHAWESHBHASKAR merged 305 commits into
mainfrom
ccr-94e2aa7d-5xfs9k
Nov 10, 2026
Merged

BHAWESHBHASKAR merged 305 commits into
mainfrom
ccr-94e2aa7d-5xfs9k

Conversation

@BHAWESHBHASKAR

Copy link
Copy Markdown
Collaborator

Summary

leep_commit_and_land and leep_resolve_and_land now run a pre-flight check. A call whose create answer carries a stopping warning the agent has not been shown creates the changeset but does not land it, and the result lists the warnings. A stopped changeset lands only with force: true.

The maintainer chose this option ("pre-flight check") to close the gap the twenty-agent demo found: the MCP tool created and landed in one call, so overlap warnings arrived only after the land.

Why

CLI agents run leep commit and see its warnings before they run leep land. MCP agents had no such pause. Now the agents most likely to collide, those with a duplicate task, a claim or the same file, are told before they land, and a call with nothing to warn about stays a single call.

Changes

The stop rule. It is decided in one place, Notice::stops_land, against the files the changeset changes, using the other side's path. A land stops on:

  • a possible duplicate;
  • a duplicate's land;
  • a land that changed one of my files;
  • a claim on one of my files or on a directory holding it;
  • another session on the file itself.

near warnings, scope directories, related files and unknown levels never stop a land.

Only unseen warnings stop. The server says each warning once per session. It now remembers which files it said a warning as a stop for, and says it again when it would stop a file it did not stop before. For example: a non-stopping warning about ben's src/b.rs, followed later by ben editing my src/a.rs, stops my commit of src/a.rs. Per session, the server prefers the overlap row that stops for the request's files. Landed warnings name the request's file. Every stopping warning comes in one answer, beyond the usual cap of 3.

A stopped call:

  • returns isError: false with structuredContent {status: "stopped", landed: false, changeset, target_ref, files_changed, stopped_by, guidance} and the text NOT LANDED: stopped before landing;
  • leaves the target ref unchanged;
  • guides the agent to land the same changeset with force: true (the guidance names the target ref), or to change its work.

Retry and force:

  • A plain retry is stopped again. It lists the earlier warnings plus anything new from GET /v1/notices, so a host that automatically retries failed tool calls cannot land blind.
  • changeset: <id> with force: true lands the remembered changeset on its own ref, with its own create_ref and land request id. No new changeset is made.
  • The MCP server remembers up to 64 stopped changesets. One is forgotten only after it lands, so a failed forced land keeps its request id.

Other changes:

  • When leep_task_start's context pack is shed, it is retried once if Retry-After is 2 s or less.
  • No CLI change: leep commit already prints warnings before leep land.
  • Demo claim 10: dee's commit into eli's duplicate task is stopped, main doesn't move, and dee then lands the same changeset with force.

Docs:

  • the ADR 0096 amendment, MCP.md, ARCHITECTURE.md and DEMO-COORDINATION.md;
  • tool descriptions and server instructions;
  • changelog.d/mcp-preflight.md.

The documented limits are:

  • create warnings that time out (150 ms bound) or a lost GET /v1/notices answer mean no stop, because warnings are advisory;
  • several MCP servers on one LEEP_AGENT_SESSION don't share stops.

Testing

  • Lint and format: fmt check, clippy -D warnings, check-version.sh and changelog-assemble.sh --check pass.
  • Full suite: cargo test --workspace against Postgres 16: 2,012 passed, 1 failed, 9 ignored. The failure is a_hanging_coordination_store_changes_no_answer, an existing wall-clock assert: 314 ms against its 300 ms limit while another build ran on the machine. It passed 5 of 5 runs alone. This PR doesn't touch it. A proper fix (a paused-clock bound test) is in progress separately.
  • MCP e2e: 43 of 43, with exact create and land counts through a counting middleware. The cases:
    • no overlap lands in one call;
    • a same-file overlap is stopped and not landed;
    • force lands without making a second changeset;
    • a stop on a non-main ref lands on that ref;
    • a retry is stopped again, with what is new since;
    • a warning already said does not stop;
    • near does not stop;
    • a failed forced land keeps its request id;
    • the resolve path;
    • the shed pack is retried (with Retry-After: 0, so it doesn't sleep).
  • Derive: 40 of 40 notice tests, including say-again-when-it-comes-to-stop for same-file, claimed and landed warnings, and every stopping warning in one answer.
  • Demo: coord_demo passes on SQLite and Postgres.
  • No wall-clock asserts in the new tests.

Fable reviewed it ("ship after must-fixes"). The three must-fixes are included: wrong-ref guidance, said-once keyed by file, and the stop judged by the other side's path. All the should-fixes and nits are included too.

Compatibility

  • API: additive only. Notice::stops_land is client logic; no wire field was removed.
  • Visible changes:
    • MCP agents can now get a "stopped" result and need force: true to land past it.
    • A notices answer can carry more than 3 warnings when several of them stop a land.
  • No migration.
  • Branch history: the branch carries a no-op merge of its earlier history (coord: fix the friction the twenty-agent demo found #281, squashed into main as 582a33e).

Generated by Claude Code

claude added 30 commits November 1, 2026 23:54
…mpotent

The nightly fuzz run found strip_comments disagreeing with itself: it
dropped whitespace at the start of the file, including the first code
line's indentation, so Ruby's ` =gin` came out at column 0, where a
second pass reads an `=begin`-style block and gives up. The lexer
decides comment_only auto-approval, and a lexer that reads its own
output differently may also read the language differently.

The file now starts a line like any other: blank lines and comments at
the top still vanish, but the first code line keeps its indentation.
As a safety net, strip_comments re-lexes its result and gives up unless
it reads back as itself, so any input class it handles this way never
auto-approves. A randomized test over every language checks the
property on the lexer itself, without the net (8 million cases ran
clean locally); the crash input joins the corpus.

Fixes #217

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
incremental_reach_rows_agree_with_full_reachability asserted that its
random history left something unreachable, and seeds 5020 and 6205 of
the nightly sweep generate histories that leave nothing (every tip
landed, named or young), failing the precondition without comparing
anything. Skipping such seeds would hide a sweep that tests nothing.

Each seed now abandons a two-changeset branch aged past grace, marks it
with one more run that may be killed, and checks that the oracle,
the incremental run and a full run all find it, so every seed compares
real results.

Fixes #218

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
replay_stack walked and rebased an unlanded stack of any length, and the
batch deadline was checked only before and after the whole replay. One
writer could create thousands of cheap changesets and land the tip on a
moved ref: the replay held the ref's leader, admission refused everyone
else, and the batch then answered 409 past-deadline, so a resend redid
all the work.

A stack longer than MAX_LAND_STACK (1,024, the landed-copy scan) is now
refused with Invalid (400) asking to land it in pieces, before anything
is replayed. The stack walk and both replay paths check a deadline at
every entry: past REPLAY_BUDGET (half of MAX_BATCH_LIFETIME) from the
batch's start, that land alone answers the retryable past-deadline 409,
and the lands replayed before it still have time to commit. rebase-onto
shares the replay and the bounds.

Fixes #158

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Since #217, strip_comments gives up unless its result re-lexes to
itself, so the comment_lexer fuzz target's idempotence assertion on it
could never fail: the nightly fuzzer could no longer find the lexer
disagreeing with itself. strip_comments_unchecked (hidden) is one pass
of the lexer with the same prechecks; the fuzz target and the in-crate
property test now assert idempotence on it, and that the check in
strip_comments never fires.

Checking the lexer itself found three more inputs the check had been
hiding, each fixed at the root: GCC splices a backslash, blanks
including vertical tab or form feed, and a newline, which the lexer
did not count as a splice; a comment between a stray backslash and the
newline became a splice once stripped, so a backslash outside a literal
that is not a splice (and not at the end of the file) now makes the
lexer give up; and `import "C"` after a comment on its line was not
seen as cgo, so any `"C"` in a Go file now is.

Refs #217

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Since ADR 0071 the review rules ask merge_bases for every changed path
and side, and merge_bases may walk (side_is_clear up to 10,000 steps,
then a paint-down walk in a criss-cross), where merge_base used to be
skiplist-cheap. A review now finds each (first parent, side) pair's
bases once and shares them between the dropped-change check and every
path's merge-parent check. A test counts the calls over a 41-path
criss-cross merge review.

Refs #160

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
The check that a skiplist row does not name its own changeset lived in
Repo::skiplist only. prefetch_skiplists decoded batched rows straight
into the derivation's skiplist cache, and skiplist_noting_generations
served them from there unchecked, so a one-entry row naming itself sent
on_first_parent_chain round forever; blame, the log filter walk, the
ancestry read-ahead and changeset creation decoded rows without it too.

Skiplist::decode_of(id, bytes) decodes and checks the row of `id`, and
every reader that knows whose row it holds uses it. A test drives the
prefetch path: the walk ends with a storage error and nothing is
cached (without the fix it hangs).

Refs #216

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
The stack cap was checked at the top of the walk, before the exits for
a parent already in the head's history or landed, which read as though
a stack of exactly MAX_LAND_STACK could be refused while the docs and
the error say "longer than". The check now sits where the walk is about
to take one more member, so the code states what it does: exactly the
cap lands, one more is refused. The test covers both sides of the
boundary.

Refs #158

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Review asked for the cost of refusing over a virtual base to be stated:
once two lines resolved a file differently in a criss-cross, a later
edit to that file conflicts on every merge of the lines until one merge
of them lands and becomes the single base, where git's recursive
strategy would merge it cleanly.

Refs #160

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Co-Authored-By: Claude <noreply@anthropic.com>
… cap

Serve the router on hyper's connection builders with a timer instead of
axum::serve, which builds them without one, so the header-read timeout
was never armed and no idle bound applied.

- --header-read-timeout (30s): a connection must send a complete request
  head in time, from when it opens and, on HTTP/1.1, from the end of its
  previous request.
- --idle-timeout (2m): a connection with no request in progress is
  closed gracefully.
- --body-idle-timeout (60s): a request body that stalls fails; a socket
  whose writes stall is closed; a large response the client reads nothing
  of is dropped by a watchdog. Read-class permits and per-token
  concurrency slots ride in response bodies, so they are now released
  within this bound when a client stops reading.
- --max-connections (10000): further connections wait in the backlog.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
…without auth

- POST bodies must be sent as application/json and blob uploads (PUT) as
  application/octet-stream; another type is 415 `invalid`. A request
  with no Content-Type is accepted when it carries an Authorization
  header, so older clients, which sent blob uploads without one, keep
  working against servers with tokens.
- Without --tokens-file, requests with an Origin header, or whose host
  is not localhost, an IP address or a name given with the new
  --allow-host (LEEP_ALLOW_HOSTS), are refused with 403 `forbidden`.
  The probes are exempt.
- leep, leep-soak, the e2e helpers and the API fuzz target send the
  types; leep-git and leep-mcp go through leep's client.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
…s address, match peer prefixes as origins

- A 500 answers with a generic message and the request id; the error
  text (a store message can name hosts, buckets or paths) is logged
  under that id. The `code` stays `internal`.
- --metrics-listen on a non-loopback address logs a startup warning;
  the docs say its labels include token names.
- --peer-url-prefix is parsed and compared on scheme, host and port,
  then path prefix, instead of as raw text. `http://host:` with no port
  allows any port of that host; a partial host is refused at startup.
  The env example uses a full origin.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
… read-only permissions

remote.yml uses the deploy SSH key; it now runs in the `production`
environment so a repository admin can require reviewers and keep the
secrets there (documented in OPERATIONS.md and DEPLOY.md). rust.yml gets
an explicit `contents: read` permissions block like the other workflows.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
leep-soak derived its server's tokens from --seed, which reports print
and CI sets to the run number. They now come from 32 random bytes made
for each run, or from LEEP_SOAK_TOKEN_SECRET to reproduce them. The
workload seed is unchanged and still replays a run's plan.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
The server now closes an HTTP/1.1 connection idle for
--header-read-timeout (30s by default). leep's client, which the git
mirror and leep-mcp use, and leep-soak's clients keep idle connections
for 20s, so a long-running client does not send a request on a
connection the server is closing.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Since prefixes are matched as parsed origins, one like `https://leep-`,
which raw-text matching accepted for `https://leep-1.internal`, would
match nothing and stop every forward without an error. A prefix whose
host is not an IP address or a complete DNS name (empty labels, a label
starting or ending in `-`, a trailing dot) now stops the server at
startup with "does not name a whole host". The e2e startup test and the
docs are updated.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Co-Authored-By: Claude <noreply@anthropic.com>
…head

A cold blame read the same main-chain skiplists one row at a time on
every request: they were cached per walk only. Skiplists read by queries
now go to one process-wide SkipReadCache on the Repo (32 MiB of the
derive cache budget, moved from the filtered log's own skiplist cache,
so the total is unchanged), used by FirstParents::list, plan_stretches
and filtered logs.

A blame answered by the newest changer's cached blame is now stored at
the requested head too, with the encoding a walk stores, so the next
blame there is one lookup. Stretches are planned only once the head walk
has not finished the blame.

On a 4,100-changeset linear history with files of 6 to 25 versions,
metadata calls per blame in a warm process fell from 119 to 95 for a
file's first blame, from 78 to 9 at a head 40 lands later, and from 68
to 2 for a repeated blame there.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
PERFORMANCE.md gains the 2,000-agent swarm runs that found the index-lag
collapse and each fix's effect (ADR 0073, ADR 0074), the agent sweep on
one 2-vCPU server (250, 500 and 1,000 agents pass every criterion; 2,000
is CPU-bound), the blame profile and the skiplist cache's effect, the
warm-blame result, sizing per vCPU, and what is not yet validated: more
than one server, lease handoff under load, a ref beyond about 220 lands
a second, and long histories.

OPERATIONS.md says to restart running servers after a repair rewrites
skiplist rows, since they keep decoded skiplists in memory once read.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
The cherry-pick onto main auto-merged them after the self-naming test
that #237 added, inside `mod tests`. The batched read in plan_stretches
also decodes with `decode_of`, so a cached list passes the same
self-naming check as every other read path.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Co-Authored-By: Claude <noreply@anthropic.com>
Every read of a tree node deep-copied the cached node: a file lookup copied
each directory on its path to take one entry, and listings, diffs, creates
and the land batch's staged-node memo copied every directory they opened.
At 1,000 agents on one 2-core server, copying and freeing nodes was about a
third of a file read's CPU and a quarter of a land batch's, growing with
directory width. Root trees were read from the metadata store on every
listing and twice per diff, about 12% of all statements, although the
value is fixed by the changeset.

The decoded-node cache, the land memo, lazy-tree derivations and the read
paths now hand out Arc<TreeNode>; lookups borrow the entry they need, and
building a tree copies a node only where it changes it (Arc::make_mut).
read_tree and read_trees keep their types as copies of the new
read_tree_shared and read_trees_shared. Repo::root_tree keeps each root it
reads or a create computes in the ancestry cache, which already held
prefetched roots; verification and repair, which substitute recomputed
rows, read the row every time.

In a 1,000-file directory on memory stores, a warm lookup fell from 49 us
to 0.7 us, a one-file diff from 395 us to 194 us and a listing from 398 us
to 317 us. No persisted format or hash changes.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Co-Authored-By: Claude <noreply@anthropic.com>
A blame walk found the first-parent ancestor at each changer's
generation by descending skiplists from the nearest known point, one
single-key read per halving of the distance, for every change. Where the
chain has no merge at or above that generation (the step's last_merge-v1
row, read with its index entry), every ancestor that high is on the
chain, so the changer is that ancestor and the walk goes straight to it,
reading its skiplist with its generation. Merge regions keep the
descent.

The blame walker's gathered reads of several derived kinds now go out as
one get_derived_kinds call (one Postgres statement per round instead of
one per kind).

On a 200,000-changeset linear history (memory stores), cold-blame
statements fell from 191 to 77 for a file of 18 versions, 728 to 372 for
75 versions and 9,406 to 3,764 for 14,743 versions. Adds a counting test
on deep chains and an ignored deep_blame_calls measurement.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Co-Authored-By: Claude <noreply@anthropic.com>
A tree that a changeset was created without (ADR 0069) is stored later,
by the land that fast-forwards a ref to it or by the changeset sweep,
nodes first and root last. When that happens after the backup's metadata
snapshot (which already holds the changeset) and while sync_blobs is
listing the source, the listing can pass a node's shard before the node
is written and then reach the root's shard after the root is written.
The pool then holds a root without its whole tree: the closure check
fails with missing_tree (seen in soak_smoke), and a restore taken with
--accept-failed-check breaks "a present root means a whole tree".

sync_blobs now reads, exactly, each directory a copied tree node names
that the destination lacks, and copies it, recursively. Children are
written before their parents, so the source holds them.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Co-Authored-By: Claude <noreply@anthropic.com>
At 1M changesets the server held 2.7 GB resident with 1.19 GB in use over
32 arenas. Cap them as the importer does, unless MALLOC_ARENA_MAX is set.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
On a cold restart at 1M changesets the 321 MB pack index load outlasted
the ready timeout, so /v1/ready timed out. Warm the blob store in the
background when the routes are built (new BlobStore::warm) and answer 503
blobs/loading, without probing, until it ends. Documents both this and the
malloc arena limit in OPERATIONS and DEPLOY.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Co-Authored-By: Claude <noreply@anthropic.com>
The untrusted fence escaped line separators and bidirectional
controls but left C1 controls (U+0080 to U+009F) raw, which some
terminals read as escape sequences; they are escaped as \uXXXX now. A
red check named `ci/test` showed as `?` because it went through the
plain-word filter; it now goes through the name filter that allows
`/`, as refs do.

The server context test's OWNERS fixture held a line of code; it now
names an owner.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
The overlap notices work on this branch reached main as c844978, the
same tree as 2da46b1; keeping it as a parent lets the branch move
forward without rewriting what was pushed.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
leep_task_start declared its session without a base, and the server
finds `landed` notices only from a base (ADR 0096). An MCP agent, which
has no working copy, was therefore never told that another agent landed
a change to its files, even the twin given the same task key: the
coordination demo's duplicate pair showed `leep_overlaps` answering
"Nothing overlaps" right after the other agent landed the task.

A session without a base now takes the head of the pack it was given,
in one more declaration; a resumed session keeps its base.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
The pack's `history` section read `created-by-v1` of the landed
changeset only. A land the server rebased onto a moved ref is a copy
with no creator row, so on a busy ref most files' last change came back
with no `created_by`: in the coordination demo the newcomer saw the
model of the agent that last changed its file but not which agent it
was. Culprits already followed the rebase chain; both now use one
helper, `Repo::created_by_through_rebases`.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
The phase 1 demo of the coordination design: twenty scripted agents,
each with its own token and agent session, on one repository through
the real leep-server, leep and leep-mcp (five agents over MCP stdio).
It shows and asserts that `leep who src/auth/` lists the three agents
there, that two agents on one file are warned before either lands, that
two given one task key are told at `leep_task_start`, that the other
hears a `landed` notice naming the changeset (CLI and MCP), that a
newcomer's first call answers owners, history with models, live work and
a red ref's suspect, and that fourteen agents working alone, concurrently,
land and hear nothing louder than `near` about each other.

One scenario serves both: the `coord_demo` test asserts the claims on
SQLite and Postgres; the `coord_demo` example, run by
scripts/demo-coordination.sh, prints the narrated transcript with a
table of the notices each agent heard. Waits are bounded retries of a
command, never bounds on time.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
The context pack work on this branch reached main as b82b270, the same
tree as 6079653; keeping it as a parent lets the branch move forward
without rewriting what was pushed.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
leep_task_start took a new session's base from the head of its context
pack. On a busy server the pack is shed (503, all search permits in use)
and the session then had no base, so it never heard of a land on its
files: the coordination demo failed this way on a 2-core CI runner. With
no pack, the base is now the target ref's head, read directly.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
A context pack probed co-changed and searched files as if they were the
agent's own, so its in-flight levels contradicted the notices in the same
answer (rex's pack: `same_file` sol "(your handlers.rs)"; the notice:
`near`) and named related files "your" file. Levels are now ranked by the
request's and the session's own paths; work met only on a related file
carries the new optional `Notice.via = "related"` and is listed after
work on the agent's own files. ADR 0097 amended.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Duplicates by task key were looked for only when a session was declared,
so only the second of a pair heard of the first; in the demo dee heard
eli as `same_file` with no task key. The task-key lookup (one indexed
listing, bounded like the other parts) now runs on every answer that
carries notices: creates, lands, `GET /v1/notices` and listings. Said
once, and still never about the same principal's own restart. ADR 0096
amended.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
A session inferred from commits (LEEP_AGENT_SESSION, no `work start`)
had no target ref and never heard a `landed` notice. Clients now send
the additive request header `Leep-Target-Ref` on `POST /v1/changesets`
(the CLI its working copy's target, `leep_commit_and_land` its
`target_ref`); inference records it when the session has none, a bad
name is ignored, and a session with no target ref is walked against
`main`. ADR 0095 and 0096 amended.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
eli's `landed` notice for dee's land of BILL-42 said only that a
changeset landed. The task-key listing now reads live and landed
sessions of the key (one indexed listing, at most 64), the `landed` walk
keeps each reported changeset's `agent.session` and creator, and a land
by a session with my task key (not my own principal) is tagged `by:
task_key`, naming that session and the key. The CLI and MCP tools add
"your duplicate landed this task; check whether your work is still
needed"; MCP keeps the key in the fence. ADR 0096 amended.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Packs repeated the same owners and last change for every co-changed
file (the `carry identical` explanation four times in the demo), and
`leep_task_start` showed a duplicate twice, in the pack and as a notice.
An owners item now covers every file with the same owners answer and a
history item every file with the same last change, listing them in
`untrusted.paths` (first file keeps the id and `path`; sizes measured
after grouping). MCP explains a policy once, and `leep_task_start` and
`leep work start` drop notices the pack already lists
(`ContextPack::shows`). ADR 0097 amended.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Three more claims: a pack ranks live work by the agent's own files as
its notices do (rex and sol call each other `near`; bo's pack never
calls ada's keys.rs bo's own); the first of a duplicate pair is told and
its twin hears that it landed the task; a task start says each thing
once. Claim 2 accepts cy's warning from its `work start` pack, which now
carries it instead of a repeated notice. The two design questions the
demo raised (a commit-only MCP tool, the 30 s notice poll) are listed as
known limits.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
A `landed` notice tagged `by: task_key` names the session that landed
it, and `one_per_session` kept one item per session id: of two lands by
my duplicate only the newest was said, and its live work folded away
too. A notice about a landed changeset now passes through untouched.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
The in-flight section read the task-key sessions only to tag a
duplicate's land, yet a failed read omitted the whole section. The pack
now reads those sessions once for both sections: a failure leaves lands
untagged and omits only `duplicates`.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
A store error reading a landed changeset's creator counted as "no
creator", so the writer-chosen `agent.session` alone could tag a land
"your duplicate landed this task", and the walk cached it. On an error
the land now carries no landing session and is never tagged.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
The newest 64 sessions of a task key, live or landed, could all be
landed under a reused key and hide an older live duplicate (and the
state is not in the task-key index). Live sessions (at most 40) and
landed ones (at most 64) are now two bounded listings run together.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
A session revived by a commit after its land kept its task key and
target ref, so an inferred session reused from review/a to review/b
kept walking review/a, and a stale task key could call a later land a
duplicate's. A revive now clears both, and while a session is inferred
the ref each commit names (`Leep-Target-Ref`) replaces the last one; a
declared target still stays. ADR 0095 amended.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
A session that named no target ref was walked against a fixed `main`,
and its `landed` notice still said "landed on your target ref". The
server gains `--default-ref` (`LEEP_DEFAULT_REF`, default `main`; it had
no such setting), and such a notice carries the new optional
`Notice.landed_on`, so the CLI and MCP tools say "landed on main".
ADR 0095 and 0096 amended.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
FORMATS says a client shows a `via` it does not know as it is, but the
CLI and MCP tools showed only `related` and dropped any other. The CLI
now prints it quoted and escaped, the MCP tools as a plain word or `?`.
ADR 0097 also notes that an older client sees only the first file of a
grouped owners or history item.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Claim 7 now needs a pack line between rex and sol (sol starts first and
lands after rex has started, so it never depends on scheduling) and a
positive "a file related to your task" line in fay's pack, for the
maintainer's live work on docs/auth.md (now written with token.rs),
listed after cy. Claim 9 needs exactly one `carry` explanation in dee's
pack (asked again when shed), and claim 2 needs cy's warning to come from
its own `work start` pack. In e2e, a dead `warned` check goes and the
undeclared agent's `landed` notice is awaited by bounded retries.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
The env-example test checks every flag's variable is documented there;
`--default-ref` was missing. Also takes a test's slice by reference, as
clippy asks.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
The coordination demo on this branch reached main as 74c86f0, the same
tree as e195f97; keeping it as a parent lets the branch move forward
without rewriting what was pushed.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Notice::stops_land is the one rule of the MCP pre-flight check (ADR 0096
amendment): possible duplicates, a duplicate's land, a land on a changed
file, a claim on or above a changed file, and another session naming a
changed file itself stop; near, a scope directory and related files do
not. So that the rule can read a notice's paths, the server now names the
file two sessions share rather than a directory holding it, and a landed
notice names a file of mine the land changed before a directory of mine.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
leep_commit_and_land and leep_resolve_and_land read the notices their
create got before landing, and stop, unlanded, on one Notice::stops_land
says to read first and the agent has not been shown. The answer names the
changeset made and the notices (fenced) and says how to go on; force:
true lands anyway, and a retry reuses the stopped changeset instead of
making another. A clean call is still one create and one land.
leep_task_start asks once more for a context pack shed with a short
Retry-After. The demo shows dee stopped and landing with force; ADR 0096
is amended, and MCP.md, ARCHITECTURE.md and the demo notes say how it works.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Said once was keyed by the other session and level, so a session first
heard of through my scope directory (or a claim on another file, or a
land on another file) never stopped my later commit of a file it does
touch. Each notice now records the files it stops a land of, and is said
again when it comes to stop one it did not; every notice that stops comes
in one answer, past the cap of three. Notice::stops_land reads the other
side's path, never which of mine it was paired with, and the server
prefers, per session, the row that stops a land of the request's files
(or the session's, on a notice read); a landed notice names the
request's file the land changed.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
A stop is no longer an error result, and a plain retry of a stopped
changeset (the same call, or the changeset by id) is stopped again with
its notices and any new ones, never landed: only force lands it. Landing
it by id without a ref lands it on the ref the stopped call named, with
its create_ref and request, and the guidance names both. A stopped
changeset is forgotten only once it lands, so a failed forced land keeps
its request's land id. Resolution stops say to send new resolutions. The
shed-pack test no longer sleeps. MCP.md, ADR 0096, ARCHITECTURE.md and the
demo notes describe the rule and its limits.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
The demo friction fixes on this branch reached main as 582a33e, the
same tree as ab82a0d; keeping it as a parent lets the branch move
forward without rewriting what was pushed.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
@BHAWESHBHASKAR
BHAWESHBHASKAR merged commit bfc6656 into main Nov 10, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants