Repository navigation
coord: fix the friction the twenty-agent demo found - #281
Merged
Merged
Conversation
Packs, metadata transactions and repack work per land with pipelining on and off, the create p50 that rose as land p50 fell, and what bounds an uncounted dropped attempt. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
In the 2,000-agent mixed read and land swarm lands ran at 52 a second with pipelining on and off alike (land p99 384 ms against 442 ms): reads limited that run, not commits. Pipelining stays available behind --land-pipeline on and becomes the default only after a run where the land leader is the bottleneck shows it gaining. The crash tests that pipeline set the flag themselves. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Co-Authored-By: Claude <noreply@anthropic.com>
A merge used one merge base, the newest common ancestor. In a criss-cross history the sides have several best common ancestors, and a three-way merge against any single one can take a side's version of a path with no conflict that another base would raise: branches that merged each other with different resolutions lost one of them silently when their descendants merged. The server must never resolve a conflict silently. Repo::merge_bases finds every best common ancestor (free in the common case through side_is_clear; otherwise git's paint-down walk, iterative and batched). The merge is made against the first base as before, then each path the bases disagree on is resolved against every base, and a path whose answers differ becomes a conflict. Refusing was chosen over git's recursive virtual base: it needs no synthetic trees or nested conflict markers, and a merge every base resolves alike stays clean. The review rules on merges check every base the same way. Lands replay non-merge stacks against their first parent and pick no merge base. See ADR 0071. Fixes #160 Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
…mselves The nightly fuzz run hit a debug assertion: a node naming a writer's pack by absolute reference, stored again in that writer's pack, made a pack that refers to itself. In a release build the encoder wrote the writer as index 0, so the reference silently read as a node of the pack's own, by a relative part. Derived rows are read back from the metadata store, and a damaged one must come back as an error that verify reports and repair rederive rebuilds, never a panic or a wrong answer. A part whose id table names its own writer, or a node naming itself or a later node of its part (which could make a walk loop), is now a corrupt history node error. The encoder writes such a reference with the writer in the table instead of asserting, so the pack fails to decode. The fuzz harness stores a node again under a writer it does not name, as a real pack does. The sibling skiplist decoder had the same shape of hazard: a row whose generations do not descend, or that names its own changeset, could send an ancestry walk round forever; both are errors now. Fixes #216 Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
…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>
POST /v1/context is a query, but the client retried only GETs, so a rate limit or a Leep-Refused shed failed the call at once; with half the cores as search permits a small server has one. The client now sends it again on both, as it does a search. leep_task_start and `leep work start` declare the session before they ask for the pack. A pack the server cannot give then (shed with MCP's retries off, rate limited, failing) no longer fails the call after the session was made: the session is reported as started, with "context unavailable" and when to ask leep_context or `leep context` again. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
`leep work start` asked for the context of its scope and claims together, up to 128 paths against the request's limit of 64, so a full declaration was refused after the session was made. It now sends the claims, then the scope, at most 64. leep_task_start sent its paths as the session's scope even when there were none, so resuming a task without paths wiped the scope. It now leaves the scope as it is then. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
One `search` read-class permit covers a whole pack: its path logs, last-change reads, ref checks and culprits take no `log_filtered` permit of their own. ADR 0097 and OPERATIONS now say so, with the bounded cost under that permit (128 MiB scanned at most, against a plain search's 256 MiB, and 2 s) and that with `search=off` only the per-token rates bound packs. A router test checks that a full search class sheds /v1/context with Leep-Refused and admits it once the permit is free. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Fixes from review of the context pack (ADR 0097): - A history item copied agent.model and agent.session uncut, and an owners item listed every name, so one 10 KiB model emptied the history section. Names are cut to 80 bytes; owners items list at most 16 names and 16 OWNERS files. - Ref health is one item with a 5% quota; suspects could push it out and the red state with it. Suspects are now dropped first (reported as cut section `suspects`), and spare budget goes to ref health before anything else. - When the caller's session lookup failed or timed out, the stand-in session found the caller as its own 100% duplicate. The stand-in now carries the caller's key, and rows of the same principal and key are never an overlap or a duplicate. - A session both live on the files and a possible duplicate was charged twice; it is listed once, under duplicates, with its paths. - The request's task key wins over the session's, as its task text does. - `sized` no longer subtracts from a zero length. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
`leep review inbox` and leep_review_inbox asked for one page (50, or 20) and then dropped the requests the caller had already answered, so when the newest page was all answered, older requests still waiting were hidden. The shared `waiting_requests` reads on, at most ten pages, until it has the limit of unanswered ones, and says when more follow. leep_review_inbox no longer tells an MCP agent to run a CLI command it has no tool for: it says the tools cannot approve or reject. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR fixes five frictions that the twenty-agent coordination demo (#280) surfaced in Phase 1 notices and context packs. It also includes the fixes from a Fable review of those changes. The demo now has nine claims; three are new and each checks a fix.
Why
The demo showed agents being told the wrong thing, or nothing:
[same_file] (your "src/api/handlers.rs")while the notice for the same overlap said[near].Changes
Friction fixes
Notice.via: "related", ranks after the agent's own overlaps, and never counts toward a crowded path. The CLI renders it as "a file related to your task"./v1/noticesand listings, not only on declaration. It is still said once, and an agent restarting under a new key is still not flagged.Leep-Target-Ref, onPOST /v1/changesets.leep_commit_and_landsends itstarget_ref.by: task_key: "your duplicate landed this task; check whether your work is still needed".untrusted.paths), andbytesis measured after grouping.leep_task_startandleep work startdrop any notice the pack already shows.Review fixes (Fable: ship after must-fixes, all applied)
duplicatessection;in_flightstays.agent.sessioncould earn the tag.--default-ref(LEEP_DEFAULT_REF, defaultmain): sessions with no target are checked against it. The notice says so through the new optionalNotice.landed_on, as in "landed on main".leep statuscheck now retries instead of running once.Docs: ADR 0095, 0096 and 0097 amended; FORMATS ("Notice and pack refinements"), ARCHITECTURE §12, CLI.md, MCP.md, OPERATIONS, DEMO-COORDINATION.md and the env example updated. Changelog fragment:
changelog.d/coord-demo-friction.md.Testing
-D warnings,check-version.shandchangelog-assemble.sh --checkpass on this head.cargo test --workspaceagainst Postgres 16, including leep-e2e, passed on the fixer's tree before its last commit. That commit only addedLEEP_DEFAULT_REFto the env example; leep-server and leep-cli were rerun after it. A full rerun on this head is in progress locally, and CI runs it too.related_files_never_outrank_my_ownthe_first_of_a_duplicate_pair_is_told_tooan_undeclared_session_hears_of_landsa_duplicates_land_is_named_as_suchidentical_owners_and_history_rows_are_one_itemeach_land_of_a_duplicate_is_its_own_noticea_failed_task_key_listing_keeps_live_worklanded_sessions_never_hide_a_live_duplicatea_revived_session_forgets_its_task_and_refa_session_without_a_target_hears_of_the_default_ref--read-concurrency search=1.Compatibility
Notice.viaandNotice.landed_on, and the optionalLeep-Target-Refrequest header.untrusted.path, so they see the first file of a grouped item;--default-ref.possible_duplicatewhere it used to hearclaimedorsame_file;leep_commit_and_landdelivers create-time notices only after the land;leep_task_startis not retried by the tool.Generated by Claude Code