Skip to content

coord: an event feed of coordination facts, long-poll and stream (ADR 0099) - #285

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

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

Conversation

@BHAWESHBHASKAR

Copy link
Copy Markdown
Collaborator

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:

  • an MCP agent between its own calls;
  • an orchestrator watching many agents;
  • a human running leep watch.

Polling /v1/notices every 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-row events_epoch.

  • CoordStore gains append_events, events_after, events_bounds and sweep_events, on memory, SQLite and Postgres, with a contract test.
  • Ordering:
    • On Postgres each append runs under pg_advisory_xact_lock, so commit order equals seq order, and events_bounds takes the same lock shared. Both set a 2 s lock_timeout, so a stuck writer cannot hold data-pool connections that lands share. The sequence is CACHE 1.
    • On SQLite, BEGIN IMMEDIATE and AUTOINCREMENT give the same order.
    • With the lock removed, the contract's test of concurrent writers and a reader failed 3 runs out of 3.
  • Retention: 24 h (--events-retention, from 1 minute to 24 hours). The sweep removes the oldest prefix in seq order, running up to 5 s a minute. A new gauge, leep_events_oldest_age_seconds, shows how far it lags.
  • Backups: events are not backed up. A restore renews the epoch, so an old cursor gets 410.

Events

  • One row per fact. The kinds are:
    • session.started, session.paths (only when the set changes) and session.ended;
    • ref.landed, for every land, with the paths it changed;
    • note.added, note.superseded and note.endorsed.
  • Events contain no prose: only ids, states, enum words and a 16-hex digest of the task key. Paths and the ref name sit under untrusted.
  • The server holds events in a buffer of up to 10,000, flushes it every 250 ms, and counts any overflow. Failures are counted and never change an answer.

Route: GET /v1/events?after=&session=&paths=&kinds=&wait=

  • Answers {events, next, more}, at most 500 events or 256 KiB.
  • The cursor is opaque, e<epoch>.<seq>; the old e<seq> form is still accepted. A cursor that has expired, comes from another epoch, or points past the newest event gets 410 cursor_expired {oldest}.
  • Long-poll: wait is clamped to 0–30 s and kept under the request timeout.
  • Streaming: SSE with Accept: text/event-stream, Last-Event-ID to 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 gets event: reset.
  • One tailer per server runs only while someone is waiting, feeding a ring of 10,000 events. A cold scan is capped at 8 pages per request.
  • Admission:
    • A single parse decides whether a request waits, in the limits middleware and in the handler.
    • A waiter place (10,000 per server, 64 per principal; otherwise 503 Leep-Refused: events) is taken before any store work.
    • Waiting reads hold no ADR 0020 concurrency slot; rate limits still apply.
  • session=KEY resolves only among the caller's own sessions. It matches other sessions' events on its paths or its task digest.

Clients

  • CLI: leep watch [PATH...].
  • MCP: each tool call with a session reads /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

  • Lint and checks: fmt check, clippy -D warnings, check-version.sh and changelog-assemble.sh --check pass.
  • Full suite: cargo test --workspace against Postgres 16 after merging main: 2,097 passed, 0 failed. The crash test passed 9/9.
  • New tests:
    • Store: the contract checks ordering under concurrent appends, sweep bounds and the epoch, plus the v10 upgrade and snapshot tests.
    • Every land is an event: a land without agent.session, a stacked land, and a person's land over HTTP that matches an agent's session.
    • Admission: comes before any store work, against a store whose reads hang; the repeated-wait bypass is closed.
    • Expired cursors: a live ring still expires a cursor from elsewhere; a stuck event appender is refused after its lock timeout; long-polls stay bounded and clamped (on a paused clock).
    • Stream and caps: SSE resume and a slow-reader reset; the admission caps; cold scans are bounded.
    • Land equivalence: with a store that works except that append_events fails, appends are tried, nothing is stored, and land answers are unchanged.
    • MCP: a landed notice arrives on the next tool call, and notices are asked for at least every 30 s, including on 5xx.
    • Two servers: an e2e on SQLite and Postgres with writers on both servers and a reader that alternates between them sees every event exactly once, in order.
    • No agent prose appears in any event.
  • No wall-clock asserts.

Fable reviewed the change and asked for four must-fixes before shipping. All four are in:

  • every land is an event, and the 30 s notices floor is kept;
  • admission is decided once, before store work;
  • the ring answers 410 for a cursor past the newest event;
  • the events lock has a timeout.

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

  • API: additive only. New route and types, new error code cursor_expired, new refusal value Leep-Refused: events, and new option --events-retention / LEEP_EVENTS_RETENTION.
  • Migration 11 is additive. A v10 server refuses to start on a v11 database, as with every migration. The rollback SQL is in OPERATIONS.
  • Branch history: the branch carries a merge of main (notes part 2, coord: note staleness, endorsement by human owners, and notes in context packs (ADR 0098) #284) and a no-op merge of its earlier, already-squashed history.

Generated by Claude Code

claude added 30 commits November 2, 2026 18:47
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
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
…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
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
- 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
@BHAWESHBHASKAR
BHAWESHBHASKAR merged commit 89fb9ad into main Nov 10, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants