Repository navigation
coord: an event feed of coordination facts, long-poll and stream (ADR 0099) - #285
Merged
Merged
Conversation
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
The first land's answer arrives before its batch task ends, and the leader keeps that batch as the one ahead until the task returns. A land arriving in that window replayed speculatively behind the finished commit and was recorded as speculated, so the test saw an extra ([a], true) entry (seen once on CI). Wait for the leader to go idle, as the other held-store tests have since 67f789d. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
A server restarted while background merges waited for their fence searched and merged the replaced packs again, since "replaced; delete after the fence" lived only in process memory (the Waiting queue and PackSet::supersede). Each restart copied the merged objects once more; one store grew from 1.5 GB to 9.3 GB. A repack now appends the packs it replaced to packs/retired (a bucket's packs/.retired, then packs/.gen) after the replacing index is published. Views read the record before tombstones and the directory, and its stamp joins the ADR 0070 stamps, so no process searches or plans a retired pack again. retire_sweep deletes retired packs past the fence, temp files and index-less packs under the repack lock (lease); it runs at server start, on each background tick and at leep-server repack start. A repack's temp files are removed on its error paths. ADR 0080. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Pack ids are content hashes, so a merge can reproduce a pack that is retired and still inside its fence (A+B into N retires A; GC deletes B's objects; a full repack writes A again and retires N). Left retired, the republished pack would hide the only copy of its objects and a sweep would delete it. The repack that publishes a pack now takes its id out of the retired record in the same write that retires the sources (a rewrite by rename on a filesystem), and out of its process's superseded set. The bucket record is rewritten with a conditional PUT on the etag it read, retried after a lease check on a conflict, so a writer that lost the lease cannot drop another holder's lines; an emptied record stays, empty. Docs: restoring a raw copy drops packs/retired; ADR 0080 notes clock skew and the sweep's write.lock at each tick. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Performance regressions were caught only by manual benchmarks on a noisy box. #253's first version gained 60% land-only and regressed the 1,000-agent swarm badly through changes of resource shape: a create making two metadata calls at once, and a skiplist plan asking the four-connection land pool for five or six. Those are counts, visible in process on any machine; wall time on shared runners is not. See ADR 0081. Operation budgets: leep_testkit::budget::Budget wraps both stores and records calls, keys and bytes per method and derived kind, and the most calls in flight overall, per lane and per actor. budget_tests.rs checks create, eight creates at once, a land batch of k, log 100, a cold blame, a search and uploads against exact lines (the flows run on one thread, so counts are deterministic), failing over budget and more than 20% under. Reapplying either half of #253's first version fails them (create 2 in flight; land lane 5 in flight against a budget of 4). deep_blame_tests and the create concurrency test move onto Budget. Ratio bench: a perf job on pull requests builds the merge base's server and this change's, and scripts/perf-ratio.sh runs them A B A B under a short leep-land-bench and a 200-agent leep-swarm (about 2.7 minutes of runs). leep-perf-compare gates hard on counts (lands not admitted, 409s, stalled agents, CAS losses, discarded attempts, shed reads, pool peaks) and on statements and bytes per land, and only on collapses of wall time; a check whose two A runs disagree is inconclusive. New counter leep_meta_statements_total{op, pool} in the Postgres and SQLite stores; leep-land-bench gains --json, leep-swarm the new fields. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
A repeated `log limit=100` re-learned its window along the skiplists on
every request (22 store calls in 7 round trips), because the read-ahead
used only the generations the ancestry cache held. It now keeps what its
batches read (parents, generations, root trees, skiplist generations) in
the ancestry cache under one lock per batch, guesses cached parents
transitively, and skips root and skiplist reads it does not need, so a
stretch the server walked or created before is one batched read. Bodies
are byte-identical to before; a property test checks cold and warm walks
against the serial walk on random histories with merges.
`GET /v1/log/{rev}` and `GET /v1/changesets/{rev}` also take a revision
(`/` as `%2F`) and name the id in `Leep-Resolved`, so `leep log main`,
`leep show main~1` and a log outside a working copy are one request; the
CLI falls back to resolving first on 400/404 (older servers). `leep show`
keeps changesets in the local cache by id, checked on read. Log,
changeset and resolve answers carry `Server-Timing` (phases, store calls
and rounds via `leep_derive::read_stats`).
On 100,000 changesets (local Postgres 16): repeated `log limit=100` 4.5-5.4
ms instead of 9.7-12.6 ms on the server, 1 store call instead of 22;
`leep log -n 100` 10-15 ms instead of 17-20 ms end to end. ADR 0081.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
On hot history every land changes the hot files, so each blame of one walked and diffed every change since the last cached blame, and a cold blame of a file with thousands of versions is mostly the walk: on a 200,000-changeset history, 3.2-3.9 s of a 4.6-5.3 s blame of an 8,200-version file, against 0.7 s of replay (0.31 s of diffs, at most 0.34 s of blob reads beyond them), so delta-chain sharing in the pack reader was not worth doing. Every answered blame now tracks its path (Repo::blame_held, at most --warm-blame-paths, default 1,024, forgotten 30 minutes after its last blame), and a blame warmer loop beside the index warmers follows each ref's first-parent chain: for each landed single-parent changeset and tracked file it wrote, it reads the cached blame of the file's newest changer at the parent (the index's only newest changer, at or above the parent's last merge) and writes the changeset's row with one diff, the replay's own apply_base, byte-identical to a request's row. No walks: a missing row marks the file cold on that ref until blamed again. It reads the history index only where derived, works 200 ms a pass at most, takes a blame read permit per pass with work, and runs on the shared pool in every indexing mode. The index warmers' ref-log tail is shared as RefTail. A request's head walk looks up the cached blame of its first changer below the tip at once and ends there on a hit, and plans stretches only when the head walk did not end the blame. A cold blame makes one more metadata call, so the blame operation budget goes from 9 + 3 per version to 10 + 3. A hot-file blame 8,000 changesets after the last one: 437 ms off, 19.5 ms on. In a 300-agent swarm with half the blames on hot files, blame p99 went from 169-234 ms to 38-46 ms and sheds fell by two thirds, with lands/s and index lag unchanged. leep-swarm gains --blame-hot. No persisted format changes: the rows are ordinary blame-v2 rows. See ADR 0083, which supersedes the unmerged ADR 0075. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
A fresh server built every Postgres pool lazily, so its first requests opened connections inside the request, and sqlx's 10 minute idle timeout made a quiet server pay again. `--meta-pool-min` (LEEP_META_POOL_MIN, default 4) keeps that many connections of the data pool and of the land reserve, and one each of the index and lease pools, open from startup on (ADR 0084). The floor opens before the listener binds, within 10 s, and a keeper tops it up; new floor connections plan their tables once so they do not meet requests with a cold backend. Commands and tests stay lazy; 0 restores the old behaviour. 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
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
Migration 0011_events on both backends and the CoordStore events calls (append_events, events_after, events_bounds, sweep_events) on memory, SQLite and Postgres. A Postgres append holds an advisory lock so commit order is seq order; SQLite has one writer and AUTOINCREMENT, so seq never goes back. The contract checks order under concurrent appends, bounded pages, the bounds and a bounded sweep in seq order. Events are left out of backups, like sessions. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Session and note hooks emit events (session.started, session.paths when the path set changed, session.ended with landed_as from the post-land hook, note.added, note.superseded; note.endorsed has a hook for notes part 2) into a bounded buffer flushed every 250 ms; failures are counted, never returned. Readers long-poll up to 30 s or stream with SSE (id is the cursor, Last-Event-ID resumes, 15 s heartbeat, 10 minute close, event: reset for a reader that falls behind). One tailer per server fills a ring while it has waiters; waiters have their own admission class (10,000 per server, 64 per principal) instead of the token's concurrency permit. Expired cursors are 410 cursor_expired; events are kept 24 h (--events-retention). The land-equivalence test now flushes events between lands and fails append_events on the broken store. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Each tool call reads GET /v1/events?session=K&after=C&wait=0 and asks GET /v1/notices only when an event bore on the agent's session (another session's work on its paths or task, a land there), or the cursor expired. A land on the agent's file is said on its next tool call instead of up to 30 s later; an idle call asks nothing. A server without events falls back to the 30 s poll. CLI: leep watch [PATH...] prints events as they happen. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
ADR 0099, ARCHITECTURE section 12, FORMATS (table, store calls, wire shapes, 410 and Leep-Refused: events), CLI (leep watch), MCP (notices on a matching event), OPERATIONS (metrics, retention, migration 11 and its rollback, events not in backups), DEMO-COORDINATION (the 30 s limit is closed), and a changelog fragment. An e2e runs two servers on one SQLite and one Postgres store while agents declare sessions on both, and a reader long-polling each server in turn from the other's cursor sees every event exactly once. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
…o packs
One function, Repo::notes_for (leep-derive/src/notes_view.rs), answers
GET /v1/notes and the context pack's new notes section, so they never
disagree. Staleness is a bounded tree lookup at the ref's head against
the note's at_blob; a missing at_changeset or unreadable tree is unknown,
never an error. POST /v1/notes/{id}/endorse and `leep note endorse` let
only a human owner (kind: human, may review, named by OWNERS, not the
writer) endorse an open, fresh path note; an endorsement lapses while the
note is stale. An owner's report now hides human and endorsed notes.
Packs rank notes endorsed > human > agent, fresh before stale, nearest
anchor, newest; stale agent notes are dropped, agent notes come as
titles only, and every word stays under untrusted (fenced in MCP, quoted
in the CLI). SECTIONS goes to 7 with a 10% notes quota taken from files
and history.
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
…0098) Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
…dorse notes_at ordered by id alone, so 256 newer agent notes on a hot path pushed every older endorsed or human note out of every pack. All three stores now answer endorsed, then human, then agent notes, each newest first, before the cap; the contract checks it with 300 agent notes and an older endorsed one. An endorsement now sets expires a year on (renewed by endorsing again) instead of clearing it, so a note left stale for good is not kept for good, and CoordStore::unendorse_notes gives operators an undo by note or by endorser. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
…eads
A pack now lists notes as omitted with reason bound (the probe hit its
cap, or notes went unchecked past the 32-check budget) or timeout (checks
ran out of time) beside the notes it has, and a store whose schema
predates notes is off, not error. Checks never start past their deadline,
closed notes are never judged, and endorse bounds its tree read and
answers 409 when staleness is unknown. Write and retract answers carry no
stale (a note is fresh at its `at`); GET /v1/notes/{id} takes ref= as
the listing does. Adds `leep-server notes unendorse --by P | --id N`.
Tests: the 32/100/256 bounds, a passed deadline on a paused clock, a
subdirectory owner refused on a parent note, endorse racing retract and
supersede, a person's read-only token, and the pack tests on a paused
clock.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
…098) Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
The notes core on this branch reached main as 96b1500, the same tree as f5a7452; 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
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
- ref.landed for every land, from the post-land hook, with the paths the land changed (ref log old head to landed, folded; top-level names past 1,000) and the ref under untrusted; a session on those paths matches. MCP asks for notices at least every 30 s, also when the events read fails, so lossy events never silence notices. - One parse decides whether a request waits, in the limits middleware and the handler alike (the last wait wins), and a waiter takes its place before any store read (tail, session lookup). - The ring answers only cursors at or below what it read; a cursor past it goes to the store and is 410 when it is from elsewhere. - Postgres: the events lock is taken with a 2 s lock_timeout, appends and bounds reads fail instead of holding a data-pool connection; the sequence is CACHE 1. - Cursors carry the store's events epoch (events_epoch, made by migration 0011, renewed when a restore clears events): e<epoch>.<seq>, e<seq> still read. Another epoch is 410. - The land-equivalence test adds a store that works except appends, and checks appends were tried. Sweep by time budget with an oldest-age gauge; at most 8 empty pages scanned per wait; the task digest read once at ring push; a stream's final reset is never dropped; stream session scope refreshed every heartbeat; wait kept under the request timeout; sub-second client waits; --events-retention 1m to 24h. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
ref.landed, cursor epochs, the lock timeout and CACHE 1, admission before store work, the 30 s notices floor, the sweep budget and its age gauge, and the retention bounds. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Conflicts in the meta imports, the API table and the docs kept both sides. The endorse path now sends note.endorsed (Repo::note_endorsed_event), tested with the endorsement test; CoordFaults passes unendorse_notes on. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Notes part 2 on this branch reached main as 5714845, the same tree as f3640d9; 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 is the third PR of coordination Phase 2. It adds a feed of coordination events at
GET /v1/events, read as a long-poll or a server-sent event stream: sessions starting, changing paths and ending, every land on a ref, and notes added, superseded and endorsed. A client can read from one server and resume on another without losing or repeating an event.MCP agents now hear about a land on their files at their next tool call, not up to 30 s later. Orchestrators can watch the whole fleet with
leep watch.Why
Agents mostly learn things from the answers they already get, but some cases need a feed:
leep watch.Polling
/v1/noticesevery 30 s was the documented limit; this replaces it with a cheap "has anything relevant happened?" read.Changes
Store: migration
0011_events, a logged table plus a one-rowevents_epoch.CoordStoregainsappend_events,events_after,events_boundsandsweep_events, on memory, SQLite and Postgres, with a contract test.pg_advisory_xact_lock, so commit order equalsseqorder, andevents_boundstakes the same lock shared. Both set a 2 slock_timeout, so a stuck writer cannot hold data-pool connections that lands share. The sequence isCACHE 1.BEGIN IMMEDIATEandAUTOINCREMENTgive the same order.--events-retention, from 1 minute to 24 hours). The sweep removes the oldest prefix inseqorder, running up to 5 s a minute. A new gauge,leep_events_oldest_age_seconds, shows how far it lags.Events
session.started,session.paths(only when the set changes) andsession.ended;ref.landed, for every land, with the paths it changed;note.added,note.supersededandnote.endorsed.untrusted.Route:
GET /v1/events?after=&session=&paths=&kinds=&wait={events, next, more}, at most 500 events or 256 KiB.e<epoch>.<seq>; the olde<seq>form is still accepted. A cursor that has expired, comes from another epoch, or points past the newest event gets 410cursor_expired {oldest}.waitis clamped to 0–30 s and kept under the request timeout.Accept: text/event-stream,Last-Event-IDto resume, a heartbeat every 15 s (which also re-resolves the session's paths) and a close after 10 minutes. A reader that falls behind getsevent: reset.Leep-Refused: events) is taken before any store work.session=KEYresolves only among the caller's own sessions. It matches other sessions' events on its paths or its task digest.Clients
leep watch [PATH...]./v1/events?session=K&after=C&wait=0. It asks for notices on the first call, on a match, or on an expired cursor, and at least every 30 s as a floor, including when the events read fails. Against a server without events it keeps the 30 s poll.Docs: ADR 0099, ARCHITECTURE §12, FORMATS, CLI.md, MCP.md, OPERATIONS (metrics, retention, migration 11 and rollback SQL, no backups), DEMO-COORDINATION.md (the 30 s limit is removed), the env example and
changelog.d/coord-events.md.Testing
-D warnings,check-version.shandchangelog-assemble.sh --checkpass.cargo test --workspaceagainst Postgres 16 after merging main: 2,097 passed, 0 failed. The crash test passed 9/9.agent.session, a stacked land, and a person's land over HTTP that matches an agent's session.waitbypass is closed.append_eventsfails, appends are tried, nothing is stored, and land answers are unchanged.Fable reviewed the change and asked for four must-fixes before shipping. All four are in:
All the should-fixes (a land-equivalence store that truly fails appends, sweep throughput, bounded cold scans, store epochs, SSE scope refresh) and nits are in too.
Compatibility
cursor_expired, new refusal valueLeep-Refused: events, and new option--events-retention/LEEP_EVENTS_RETENTION.Generated by Claude Code