Repository navigation
coord: notes, agents' shared findings anchored to files (ADR 0098) - #283
Merged
Merged
Conversation
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 changeset sweep (ADR 0059) deletes the original of a rebased land
once its copy's scan window and grace have passed, and keeps the
reachable copy's rebased-origin-v1 row on purpose ("Kept"): it is 64
bytes, and signatures, reviews and land-id answers only compare or look
up the id. verify still required the original to exist, so every soak
that swept such an original failed with bad_rebased_origin.
verify now checks what still holds after a sweep: the copy exists and
its leep.rebased_from (hashed into its id) names the row's original. A
missing original is legal; an unreadable one is still damage. That also
catches a row naming a stored but wrong original, which the old check
missed.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Co-Authored-By: Claude <noreply@anthropic.com>
On one 4-vCPU box with the same cores for each: Git agents pushing to one bare repository (rebase and retry on rejection) against leep-land-bench on main at #229 and at #246, at 16, 64 and 256 agents, plus the 1,000-agent mixed swarm before and after. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Uploads to a filesystem store each fsynced their own file before the rename that publishes it; at swarm load that was a sync per upload on a CPU-bound server. Puts that arrive while others are in flight now join a batch that waits up to --blob-fsync-window (default 2ms, 0 for the old path), is made durable by one syncfs (Linux 5.8+; an fsync per file elsewhere) and only then renamed into place. A lone put syncs at once. A blob is still under its final name only once its bytes are durable, and a put still returns only after that; directory syncs stay deferred to flush (ADR 0044, 0048). Shared syncs run one at a time and count failures so a writeback error another batch's syncfs took still fails the put it belonged to. ADR 0076. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
derive_range found a missing range one frontier at a time: on a linear chain, the warmer's usual gap since lands rebase, that was a row read and a changeset read per changeset, all serial and all before anything derived. A frontier of at most 8 is now followed a first-parent chain at a time: the chain is listed down the skiplists a level per batched read, bounded by the first row (or changeset already found) on it, then read in batches, each changeset kept only while it is the first parent of the one above. Wider frontiers and side branches walk as before, and the range, its order and the rows written are unchanged (ADR 0077). On local Postgres the history index catching up a 4,000-changeset gap made 138 metadata reads instead of 8,083 and derived 5,000 changesets a second instead of 1,550. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
The maintainer asked for completed milestones to be marked closed. M1 to M3 have every item done and no further exit criteria. M4's exit criterion (both indexes derive lazily on a 100k-changeset repository) holds on all of Linux, 1,484,762 commits, recorded in PERFORMANCE.md. M5 and M6 stay open: M5 has an unchecked item and a two-week exit window, M6 needs design partners. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Add leep-history-gen, a resumable bulk writer of a linear monorepo-shaped history that goes straight through Repo, and use it to put 1,000,000 changesets under the 1,000-agent swarm on the 4-vCPU box. PERFORMANCE.md gains "One million changesets of history": store growth, index warm-up, cold single operations, the swarm deep against shallow and a same-tree control, and a land-only comparison that locates the land tail in replay. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
A cold blame of a file changed thousands of times (about 61 s on a 1M-changeset history) never finished: it wrote its answer only at the end, the request timeout dropped it at 25 s, and every retry started again from nothing. The replay now writes the blame of each 256th complete version as an ordinary blame-v2 row, which a later walk stops at. A blame runs on its own task, joined by concurrent requests for the same blame, and caches its answer even after its request has timed out. A detached blame keeps its read-class permit (ADR 0074), and their number is bounded. No format change: only more keys of the existing kind. ADR 0078. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
On a deep history each rebased copy built its skiplist by reading about log2(depth) rows one after another, inside the land leader's replay; the high levels are a different row for every copy, so no cache helped. A land batch now reads the rows its copies' skiplists are built from a level at a time for the whole batch (one get_derived_many per level), on a task started as soon as the attempt knows its head; a copy waits for it only to build its list. A create also computes its generations and skiplist beside its blob checks and tree write instead of after them. Stored rows are byte for byte unchanged (ADR 0079). 150,000 changesets deep, leep-land-bench --files 2: 143-186 -> 249 lands/s at 16 agents, 204-215 -> 297-327 at 64, replay 2.8 -> 0.53-0.56 ms per land, as fast as on the same tree 1,000 deep. Test bookkeeping of a batch's lands now records each land as it is taken, before any answer, so a test holding its answers finds the batch. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
7648dc2 let store_changeset read a create's generations and skiplist beside its blob checks and tree write; on a cold process a client create then made two metadata calls at once, against ADR 0067's rule. They run after the tree write again; a land copy's checks are answered in memory, so the overlap gained it nothing. A new test fails if a create has two metadata calls in flight (it saw two on 7648dc2). The skiplist plan now starts after the batch attempt's own reads (fence flag, recorded lands, prefetch, origins), so it does not compete with them for the land pool's connections, and at most two plans run in a process; a batch past that plans nothing and its copies read as before. New metrics to confirm the swarm's cause: leep_land_batch_seconds, leep_land_skip_plans_total{outcome}, leep_land_skip_plan_seconds and leep_land_skip_plan_wait_seconds. ADR 0079, ARCHITECTURE, OPERATIONS, PERFORMANCE and the changelog are updated to match. Co-Authored-By: Claude Opus 5.5 <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
Notes are shared memory between agents, anchored to a path or a
changeset: the largest new prompt-injection surface, so this first part
is the store, the API and the limits.
- core: Note, NewNote, NoteFilter, NoteChange and seven CoordStore
methods (add_note, get_note, list_notes, notes_at, change_note,
revoke_notes, sweep_notes), checked by check_notes_contract on the
memory store, SQLite and Postgres. Note text is plain: no C0/C1
controls (newlines in a body), line separators or bidi controls.
- meta: migration 0010 (notes); writes take a per-principal advisory
lock on Postgres so the open (200) and hourly (30) caps are exact,
and 16 concurrent supersessions of one note make one winner. Notes
are backed up and restored, unlike sessions.
- derive: Repo::add_note resolves a path anchor at the session's target
ref (else the default ref) and records its blob; listings, retract,
report (hides an agent note at once), revoke and a bounded sweep.
The land-equivalence property test writes notes between lands.
- server: POST/GET/DELETE /v1/notes, POST /v1/notes/{id}/report; 429
past the hourly cap, 400 too_many_notes past the open cap. The
tokens file gains an optional kind (agent|human); a human token's
notes are trusted as a person's. gen-token --kind, whoami.kind, and
`leep-server notes revoke --principal P`.
New table and /v1 routes; additive. See ADR 0098.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
The CLI side of notes (ADR 0098). A note's title, body and anchor are
printed with {:?}, so another agent's newline, escape sequence, line
separator or bidi control never reaches the terminal as text of its
own; the header carries only what the server vouches for. A
--changeset REV anchor is resolved client-side, as short ids are
everywhere else, and --agent-model defaults to LEEP_AGENT_MODEL.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
The MCP side of notes (ADR 0098), 26 tools. Outside the fence the text says only what the server vouches for (id, kind, principal, trust, state, endorsement, supersession); every anchor, title, body and model goes in one JSON row per note inside the ADR 0095 fence, under the fixed line. Endorsed notes are fenced too. Tool descriptions are fixed text and quote no note. A unit test runs the ADR 0097 injection fixtures through titles, bodies and anchors, and an end-to-end test checks a real server's answers. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Writes a note on a file and one on a changeset with the real binaries, lists by path, shows, supersedes and retracts, and checks that the CLI prints another agent's newline quoted rather than as a line of its own. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
ADR 0098 records the notes core: the table, the store contract, trust fixed from the tokens file's kind, the rule that only a human owner may endorse (the endorsement route comes in the second change), the caps, expiry, supersede, report and revoke, the injection rules and why notes are backed up when sessions are not. ARCHITECTURE section 12, FORMATS (schema, API, error code, tokens file), CLI.md, MCP.md, DEPLOY.md and OPERATIONS.md (metrics, migration 10, upgrade order for kind, revoke, backups) follow, with the changelog fragment coord-notes.md. Also satisfies clippy in the v9 migration fixture. 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
`leep note add --at` and leep_note_add's `at` now take a short id, as --changeset and every other revision argument do; the server resolves only refs and full ids. Also notes in ADR 0098 that a report is open to any write token and leaves the note readable for audit, and that `anonymous` clients share one principal's caps. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
a_hanging_coordination_store_changes_no_answer asserted that a create took under 300 ms of wall clock against a 250 ms bound, and failed under load (306 ms in a full workspace run). Split it: the integration test keeps the equivalence half (answers and ref logs equal with a hanging store and with none), timed only by a 10 s liveness timeout, and a unit test drives after_create against a hanging store under start_paused and requires the elapsed tokio time to equal CREATE_HOOK_BOUND exactly. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
GET /v1/notes?path= took a repository path of any length, and the stores bound the path and every directory above it, one value each: a 64 KiB a/a/a/... path made about 32,000 binds. A listing's path is now refused past MAX_PATH_BYTES (256, so at most 128 ancestors), in Repo::notes and in every store through NoteFilter::check, and notes_at refuses more than 512 anchors or one past 256 bytes. The contract checks both on memory, SQLite and Postgres. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
The model a note records comes from the request (or the writer's session), so it is the writer's word, yet NoteEntry carried it beside trust and principal, checked only as one line of 128 bytes: bidi controls and line separators passed, and MCP structured output and `leep --json` showed it with what the server vouches for. It moves to NoteText (NoteEntry is new in this change, so nothing older reads the old place), the MCP rows and CLI already fence or quote it, and it is held to the note text rule. A session's model that would fail that rule is not copied onto the note. The server injection test now sets the model in requests and requires it gone once untrusted is stripped. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
A report hid an agent's note at once, but nothing recorded who reported it, nothing limited how many, and nothing could undo it: a hostile token could silence every agent note unseen. Now: - migration 0010 (unshipped, edited in place) gains reported_by and reported_at, with a partial index on them; - NoteChange::Report carries its reporter and cap; the store counts the reporter's hides in the last hour in the same transaction as the hide (Postgres under the per-principal lock) and refuses past 30, which the server answers 429 rate_limited with Retry-After; - every report is logged at INFO with note id, reporter and effect (no note text, no principal metric label); NoteEntry gains reported_by; - unreport_notes and `leep-server notes unreport --id N | --reporter P` open reported notes again with an agent note's life; - closing a note (supersede, retract, report, revoke) never lengthens its life: an expired note reported before the sweep stays due. Backups carry the new columns. Contract, derive and router tests cover attribution, the cap, the undo and the expiry. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Note text refused controls, separators and the bidi embeddings and overrides, but let through what people cannot see and models still read: zero-width spaces and joiners, the LRM/RLM/ALM marks, word joiners and invisible operators, the BOM, interlinear annotation controls and the tag characters U+E0000 to U+E007F, in which a whole instruction can hide. They are refused in titles, bodies, anchors and models alike; the server injection test now sends them as refused fixtures instead of allowing U+200F. 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
The hourly cap alone let a principal write 720 notes a day, each kept 30 days after it closed: about 100 MiB a principal. A daily cap of 100 (429 like the hourly one, metric reason "daily") bounds what one principal keeps to about 3,200 rows, some 15 MiB in the worst case, now stated in ADR 0098 and OPERATIONS. Migration 0010 (unshipped, edited in place) gains notes_open, a partial index on (repo_id, principal) WHERE state = 'open', so the open cap counts only open rows instead of every note a principal kept. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
A note write waited on its principal's advisory lock with no limit while holding a pool connection, so one stuck writer could pile up connections of the same principal. The lock is now taken with SET LOCAL lock_timeout = 2s; past it the write (or report) is refused before writing anything, as 503 with Leep-Refused: note-lock, which is safe to retry. A Postgres test holds the lock from another connection and requires a refusal, not a hang, while other principals write. The note sweep shared the heartbeat loop with no timeout; it is now bounded by HOOK_TIMEOUT (counted as reason "timeout"), tested on a paused clock. ADR 0098 also says that at_changeset may be unlanded and is no GC root, so staleness must tolerate it missing, and that a note's session_id is informational. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Review nits on notes:
- leep note printed the principal and endorser raw; a tokens file may
hold any name, so they (and the new reporter) are shown as they are
only when made of letters, digits and `_ - . @`, else quoted and
escaped.
- DELETE /v1/notes/{id} read the note in the handler, again in
Repo::retract_note and a third time after the update; it now reads it
once and answers what the conditional update wrote (a note already
closed is read again, as it is).
- leep_notes takes `before` and says when more notes follow, so an
agent can page past the first 100.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD # Conflicts: # crates/leep-mcp/tests/e2e.rs
The pre-flight check on this branch reached main as bfc6656, the same tree as f3166a7; 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
Collaborator
Author
|
The first Re-running the job once on the same head passed every check. A local A/B against main also passed all 23 checks (0 failed, 1 inconclusive), though that box was loaded and one of its A runs was noisy. The change adds nothing to the land path. Its only new periodic work is a notes sweep bounded by Generated by Claude Code |
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 is the first PR of coordination Phase 2. Agents and people can now leave notes: a finding, a gotcha, a decision or a how-to, anchored to a path or a changeset, for the next agent working there. Each note carries:
kind: agent|humanfield in the tokens file;All agent-written text stays inside the untrusted fence.
PR 2, still to come, adds staleness, endorsement by human owners and a notes section in the context pack. The maintainer decided that only human owners may endorse a note, that notes go into backups, and that the session-head GC root is deferred.
Why
Agents keep rediscovering the same facts ("this test needs the cache warmed", "don't touch this file without telling billing"). Notes let them hand those facts to the next agent on the same files. Notes are also the largest new surface for prompt injection, so this PR is mostly limits and fencing.
Changes
Store and migration
0010_notes(memory, SQLite and Postgres; contract-tested)notestable holding:at_changesetandat_blob, and an optional line range;agent_modelandsession_id;endorsed_byandendorsed_at(PR 2 writes these);superseded_by,reported_byandreported_at;API (additive)
POST /v1/notes, which returns aNoteEntry. Everything an agent wrote (title, body, anchor andagent_model) is underuntrusted.GET /v1/notes, filtered bypath(which matches the path and the directories above it),changeset,principal,mineorstate, with paging.GET /v1/notes/{id};DELETE /v1/notes/{id}, which the author uses to retract.POST /v1/notes/{id}/report.Limits
Size: a title is one line of at most 200 bytes; a body at most 4,096 bytes; a path anchor at most 256 bytes. A listing
pathover 256 bytes is refused.Characters refused at write: C0 and C1 controls (the body may contain only
\n), U+2028/2029, bidi controls and marks, zero-width characters, U+FEFF, U+2060–206F and Unicode tag characters.Per principal:
too_many_notes);rate_limitedwithRetry-After).On Postgres the caps are exact under a per-principal advisory lock with a 2 s lock timeout (503,
Leep-Refused: note-lock). On SQLite they are exact throughBEGIN IMMEDIATE.Expiry: an agent note expires after 90 days and a human note after 365. A closed note is deleted 30 days after it closes. The existing per-minute sweep does this, bounded by
HOOK_TIMEOUT.Trust:
humanwhen the writing token haskind: human, otherwiseagent. Nothing in a request can set trust. Upgrade every server before a tokens file useskind, because the file rejects unknown fields. The upgrade order is in OPERATIONS.Reports
reported_by,reported_at) and logged by note id and reporter, never the note text.leep-server notes unreport --id N | --reporter P, and remove a principal's notes withnotes revoke.Clients
leep note add|list|show|retract|report; agent text and non-plain token names are escaped.leep_note_addandleep_notes(with paging), which brings the tool count to 26. Outside the fence MCP shows only the id, kind, principal, trust, state and supersession.Backups: notes are included, unlike sessions. Restoring resets the id sequence. A restored note's
session_idis informational and may point to a session the restore does not have.Docs: ADR 0098, ARCHITECTURE §12, FORMATS, CLI.md, MCP.md, DEPLOY.md, OPERATIONS (metrics, migration 10, the order for adopting
kind, revoke and unreport, backups, worst-case storage of about 15 MiB per principal) andchangelog.d/coord-notes.md.Also: the wall-clock test
a_hanging_coordination_store_changes_no_answer(it asserted under 300 ms against a 250 ms bound and flaked) is split in two:Testing
-D warnings,check-version.shandchangelog-assemble.sh --checkall pass on this head.cargo test --workspaceagainst Postgres 16 passed 2,023 tests with 0 failures. A full run on the merged head is in progress locally, and CI runs it too.upgrade_from_v9_adds_notes;agent_model. They stripuntrustedand check that no planted string remains, and that refused strings are never echoed;Fable reviewed the change and returned "ship after must-fixes". The three must-fixes are in:
agent_modelmoved underuntrusted;So are all the should-fixes and nits.
Compatibility
kindis optional and absent means agent. A file without it fingerprints the same as before.Leep-Refused: note-lock.Generated by Claude Code