Skip to content

fix(history/semantic): key summaries by creation time, and keep the sync cursors per branch - #689

Merged
nissa-seru merged 15 commits into
anima-research:mainfrom
nissa-seru:fix/semantic-summary-keys
Oct 24, 2026
Merged

nissa-seru merged 15 commits into
anima-research:mainfrom
nissa-seru:fix/semantic-summary-keys

Conversation

@nissa-seru

@nissa-seru nissa-seru commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

The semantic index keeps a summary apart from another branch's summary with the same id, and each branch sends its own messages and summaries, so a branch made from an earlier point, as an undo makes one, no longer loses them from the index or finds another branch's.

Problem

context-manager mints summary ids L<level>-<n> from a counter in the store's branch-following state. A branch made at an earlier sequence, as /undo makes one with createBranchAt, starts from the counter as it was there, so it mints the ids its parent already used, for different summaries. Messages' ids are the store's record sequences, which don't repeat.

The history module's index had three consequences of that. Our probes on real paths showed the first two (a real ContextManager with the autobiographical strategy, context-manager 0.14.1 and chronicle 0.4.0, the real HistoryModule, and a fake embed service), and this change's tests show the third, red on main:

  • One item per summary id. A summary was indexed as sum:<id>, so the branch that synced last replaced the other branch's summary.
    • In the probe, the trunk's L1-2 was about bananas, an undo's branch minted its own L1-2 about cherries, and after the branch synced, the index's sum:L1-2 held cherries.
    • Back on the trunk, "bananas" found nothing, and "cherries" returned sum:L1-2.
  • One summary cursor for every branch. A pass sent the summaries created at or after the service's max_cursor for summaries. A summary minted on a branch and not synced before a switch was never sent once the other branch synced newer ones. In the probe, the side branch's "dates" summary was never sent, and "dates" found nothing there.
  • One message watermark for every branch. The message walk started at the service's message max_cursor, less a 10-minute overlap. A branch's messages stored before another branch's later synced ones, by more than the overlap, were never walked.

Changes

  • Keys: a summary is indexed as sum:<id>@<created>, the time context-manager stamped when it minted the summary (SummaryEntry.created, which getSummariesInRange gives as createdMs). A branch that inherits a summary keeps its time, so the summary keeps one item. By our reading, two branches' mints of one id come at different times: the store is on one branch at a time, and each mint follows its own compression call.
  • The check at search: a sum: hit is kept when this branch's summary with that id was minted at the key's time. Otherwise it's another branch's, dropped as off-branch and counted in index.droppedOffBranch. Hits still show sum:<id>, so semantic_search's and extract's texts don't change.
  • The cursors: each branch's sync cursors are kept in the history module's state as semanticCursors, beside the pending edits and removals. They hold the creation time through which its summaries are sent, the time through which its messages are sent, and the index's namespace. Module state follows the store's branches, so a branch made at an earlier point starts from the cursors as they were there, and another branch's passes never move them.
    • A pass reads the cursors when it starts, so a switch between passes is seen.
    • It writes them once, after its last send, and only when every read it made of the store (the summaries, each message page) was on the branch it started on, and the store is still there. So a switch during a pass, even one that comes back, can't put one branch's positions in another's cursors. A failed summary or message upsert ends the pass, which writes none; an edit's failed upsert is queued again, and the pass goes on.
    • A pass that moves neither cursor writes no state. A branch's first pass after the upgrade writes its cursors once.
    • A namespace that doesn't exist, as after an index is deleted, starts the cursors over, and the pass that finds it so writes that at once, so a pass that fails partway through sending everything again still finds them over.
    • Cursors kept for another namespace are none in this one. Summaries are then all sent again, and messages start at that namespace's watermark, as before.
    • When /stats answers, a kept cursor is taken as no later than the index's newest item of its kind, and a kind the stats don't list has none: a branch's cursor is its own while the index holds what it sent, and the index's when it doesn't. Every branch's items are in the one index, so a newest item older than the cursor means the index may have lost what this branch sent. An index that keeps its namespace and loses its newest items, as a restore from an older backup leaves it, then gets them again from the next pass, as main's watermark gave them (Ezra-2423's find, Ada-1017's call). Nothing is lost when the newest item the branch walked had nothing to index, such as a summary that's blank once trimmed, or was removed since, as a removed newest message is. The pass then re-sends what lies between, which the service finds unchanged.
    • A branch with no message cursor starts its walk at the service's watermark, as before, so the upgrade doesn't walk every history again (Ada-1017's call). Its first pass starts there, so its own messages older than another branch's newest synced one, by more than the overlap, aren't walked, then or later: that pass writes the branch's cursor from where it walked. That's a branch from before the upgrade, or one made at a point before the first pass after it.
    • A pass whose budget runs out inside a millisecond's summaries stops there and leaves the summary cursor at the last millisecond it finished, so the next pass sends the rest of that one, deleting their old items, and re-sends what it wrote there at no cost to the budget.
    • Summaries at the cursor's millisecond are sent again, which the service finds unchanged, so it costs no budget, and one minted there after the pass that wrote the cursor is still sent.
  • Old items: an index written before this holds sum:<id> items. Each summary a pass sends that was created after its branch's cursor also puts its old key in that pass's delete batch, so the old items go as their summaries' new ones come in, whatever the pass budget. A summary minted in the cursor's millisecond after the pass that wrote it is sent by the next pass with no delete: it was minted after the upgrade, so an old item under its id is another branch's. The exception is a restore from a backup taken before the upgrade, which can leave the cursor on a summary's own millisecond (below, under Out of scope).
    • A delete of an id the index doesn't hold is a no-op for the service (anima-research/connectome-embeddings index_delete, whose tests pin a second delete as 0).
    • Old keys go a batch of ids to a request.
    • A delete that fails leaves the summary cursor, so the next pass sends those summaries and deletes again. The service finds them unchanged, so they cost no budget, however many have come since the cursor while the deletes keep failing.
    • Search never shows an old-form hit.
      • One whose id names no summary on this branch is another branch's, and it's counted in index.droppedOffBranch, as semantic_search's description says of a summary from a branch you left.
      • One whose id this branch has can hold this branch's summary or another branch's under the same id, so it isn't counted.
      • Until a branch's passes send a summary under its new key, a search on it can miss that summary. The search's own catch-up writes up to maxSyncBeforeSearch items first, after any queued edits, summaries before messages.
    • Where the service lacks /delete, the old items stay, and search leaves them out. The warning says later removals are dropped too, as the removals' warning does.
  • A helper for deletes: deleteIds holds the rule that a /delete 404 in a pass whose /stats answered with stats means a service without the route. Removals and old summary items both use it.
  • A search says the index is behind unless its pass read the branch it answers on. The indexer keeps the branch each pass read, by its report. A search that joins a pass already running, or that the store moves while it waits, can't take that pass as its own branch's.
  • The cursors without module state. Before start() wires the module's state, and after stop() unwires it, the indexer goes on from the cursors it last wrote for each branch, which it keeps in memory beside every write. A branch it wrote none for starts with none, so it sends every summary again and walks messages from the service's watermark.
  • The host's log names a summary hit whose ts or meta can't be read as sum:<id>, as the hit is shown.
  • The sync budget, maxSyncPerTick and maxSyncBeforeSearch, is now an exact maximum of what a pass writes:
    • A pass spends what the service reports each upsert wrote, inserted or updated, and each queued edit spends one. A re-send the service finds unchanged spends no budget and needs no embedding, though the service still checks it and refreshes its metadata.
    • Pending items are sent once they could write all the budget left, so no upsert writes past it, and a pass stops exactly where the budget runs out, inside a millisecond's summaries or a page of messages too.
    • A pass that stops inside a page leaves the message cursor at the last message it sent; the next pass's overlap sends the rest of that millisecond, and the service's count decides what that costs.
    • Every chunk goes out no bigger than the budget left. So a pass whose summaries left less than a batch sends the overlap's re-sends a few at a time: up to the overlap's messages divided by what's left, each call writing nothing.
    • Deletes aren't counted. A pass sends its queued removals whatever the budget, and it deletes the old items of the summaries it sends.
    • A budget of 1.5 writes one item, and one that isn't a number is no limit, as it was before, still sent a batch at a time.
    • An upsert's answer without both counts fails the pass, since the budget spends them.
    • Unchanged re-sends can be many, with nothing embedded among them. That happens while a delete of old items keeps failing (above), and once, on the first pass on a branch with no cursor after the upgrade, which re-sends the summaries the branch shares with branches already synced. The docs name both. That first pass can hold up a search on the branch, so we timed it (Tests).
    • A search or a tick that finds a pass already running waits for that pass, under that pass's budget, rather than starting its own.
    • The knobs' docs say the most items a pass writes to the index: what the service reports each upsert inserted or updated, with each queued edit counted as one. A changed fragment says what moved from 0.26.2's text, which called them the most items a pass would push: the walk checked its budget once per page of 256, and messages the overlap behind the cursor found new, such as one written late, went uncounted.
  • Changelog: a fixed fragment, and a changed one for the knobs. 0.24.0's entry from fix(history/semantic): a summary hit's snippet comes from the local summary #605, on a summary id another branch already used, ends "its score, timestamp and date filtering are still the index's". This change makes that untrue for summaries under their new keys, and the fragment says what holds now.

What each part is for

  • The creation time in the key: it's what tells two branches' summaries under one id apart, and a branch that inherits a summary keeps it. A hash of the text would change when the text or maxChars does, and then every long summary's hits would be dropped, with nothing resending them (Ada-1017's call).
  • The check at search: without it, a hit on another branch's summary would show this branch's text, as fix(history/semantic): a summary hit's snippet comes from the local summary #605 left it, ranked and dated by the other's.
  • Showing sum:<id>: the tool texts describe hits by that id, and extract refuses a sum: id by its prefix. A changed id would change what the model reads.
  • Cursors in module state, with no branch key: module state already follows branches. A map keyed by branch id would give every undo's new branch no entry, so it would resend all its summaries (Ada-1017's call).
  • The message cursor: without it, the message walk keeps the third defect above. Starting a branch with none at the service's watermark keeps the upgrade from walking every history again (Ada-1017's call).
  • Reading the cursors at each pass, and writing them only when every read was on the branch read, after the last send: without the first, a pass after a switch would use the other branch's cursors. Without the second, a switch during a pass's upserts, or one away and back, would write one branch's positions into another's cursors, and that branch would skip its own unsent messages and summaries. Writing after the last send means a failed upsert moves nothing.
  • Taking a cursor as no later than the index's newest: an index restored from an older backup keeps its namespace, so nothing starts the cursors over. Without it, what the index lost behind a branch's cursors would never be sent again, where main, whose watermark is the index's newest, sends it.
  • Starting over for a missing or other namespace, and saving a deleted index's reset at once: with the cursors kept locally, a deleted or newly configured index would otherwise get only what's newer than them, and a rebuild that failed partway would find the old cursors again.
  • Unwiring the cursors at stop(): a pass after it would read no cursors from the state it can no longer reach, and lose every write, so it would send everything each time.
  • Keeping the in-memory cursors by branch, and writing them beside module state: kept as one set, a pass on another branch would take the last branch's cursors and skip its own unsent summaries, the defect this change fixes. Written only without module state, they'd be empty at stop(), so the first pass after it would send every summary again.
  • Naming an unreadable summary hit by sum:<id>: that's the id the hit is shown by, so a host reading the log can find it.
  • The cursors the indexer keeps itself: an indexer the host hasn't wired to module state, between bind() and start(), or has unwired, still keeps its cursors in memory. Without them, each of its passes would send every summary again, a request per batch.
  • Checking the branch at each read, not only at the end: a pass the store leaves and comes back to between two reads would otherwise write positions it read on the other branch. The reads are the summaries and each page of messages, and the edits' upsert comes before the summaries are read.
  • Saying more when the store left the branch during a pass, or a delete of old items failed: a pass the store left writes no cursor, so the branch may have items it didn't send, and old items a failed delete left are still to go. A search should then say the index may be behind, and so should one the store leaves the branch its catch-up synced while it waits. An edit or removal a failed send queued again leaves more as main has it.
  • The indexer's branchId(): one reader of the current branch, for the pass, its cursors and search. A store without branches reads as '', which keys the in-memory cursors as any other branch id does.
  • Ties at the cursor's millisecond: sending them again (>=) catches a summary minted there after the pass that wrote the cursor read the store. The service finds the rest unchanged, so they cost no budget.
  • The summary cursor at the last millisecond a pass finished: a pass that stopped inside one and moved the cursor there would leave the rest of that millisecond's summaries to be sent as ties, which don't delete their old items.
  • Spending what the service writes: counting every item a pass sends would make the overlap's re-sends spend the budget, which froze passes once: the overlap held more than a pass's budget, and every pass spent it re-sending indexed items. The service's inserted and updated are exactly the items it embeds (anima-research/connectome-embeddings plan_upsert, which gives an item it holds with the same text a metadata refresh only), which is the work the knobs pace.
  • Sending pending items once they could fill the budget left: a batch bigger than what's left could write past it.
  • Old-key deletes a batch at a time: re-sends are free, so while a delete keeps failing every pass re-sends the summaries since the cursor. Their old keys would otherwise go in one request that grows with each new summary.
  • Counting an old item whose id names no summary on this branch as off-branch: semantic_search's description, which this change leaves as it is, says a summary from a branch you left is dropped and counted in index.droppedOffBranch (Ada-1017's call).
  • The branch each pass read, kept beside its report: a search that joins a pass begun on another branch, or that the store moves while it waits, would otherwise take that pass as its branch's. It's kept beside the report rather than in it, so SyncReport doesn't change.
  • Checking an upsert's answer for both counts: the budget spends them. Without them it would be NaN, and no stop or chunk limit holds under NaN.
  • Flooring the budget: a fractional budget would let a pass write one item past it.
  • syncCursors() private: search no longer reads a cursor, so nothing outside the indexer does.
  • The fake services' counts (tests only): a fake that answered every item as inserted would make a re-send spend the budget, as the real service doesn't.
  • No write for a pass with nothing new: a pass runs every minute, and each write is a store record.
  • Old items deleted as their new ones come in: without it, every summary would have two items for as long as the index lives, and the old copy would take a hit's place in the ranking. Deleting each sent summary's old key needs no record of where an upgrade started (Ada-1017's call).
  • The cursor left after a failed delete: without it, a failed delete would leave old items for good.
  • The fake service's failures, hooks and counts, the cursors helper, and the rig's overlap, batch and search-limit options (tests only): they let the tests fail an upsert or a delete at a chosen point, switch branches while a call waits (the upsert hook sees what it was sent, to wait for a pass's messages), count calls, read the cursors as the module keeps them, decide the message walk's start by the cursors alone, flush once per page of messages, and ask a search for one hit, so the answer can hold an old item without this branch's keyed one.
  • threeTopics and loseNewest (tests only): three topics at times of their own, and an index that loses its newest items of a kind while keeping its namespace, for the clamp's tests.
  • stubStore, stubModule and keysOf (tests only): a store of fixed messages and summaries for the budget's cases, a module over it whose cursors a test can read, and the index's keys of a kind.
  • Never showing an old-form hit: a service without /delete, or a delete that failed, keeps old items, and an old item can hold another branch's summary under the same id, so it could show that summary with this branch's text. Leaving them all out is one rule. Its cost is a search right after the upgrade missing a summary its branch hasn't yet re-keyed, until the catch-up or a background pass sends it. Keeping old-form hits until a branch's cursor covered their summaries would have closed that gap, at the price of a second search rule.
  • deleteIds: the 404 rule is the same for removals and old items, so it's in one place.

Tests

  • CI at d0bb7239: green, 12 of 12 checks. Run 40078600162 checked out 46418bd, GitHub's merge of d0bb7239 into main 95c65565. On Ubuntu with Node 24 the suite ran 7,119 tests: 7,074 pass, 45 are skipped, and none fail. The changelog check passes (run 40078600164).

  • CI at 8023a9be: green, 12 of 12 checks. Run 40065070563 checked out 9caeeb6, GitHub's merge of 8023a9be into main 8ec1de5a. On Ubuntu with Node 24 and the latest, the suite ran 7,099 tests: 7,054 pass, 45 are skipped, and none fail. The Node 22.12.0 legs, on Ubuntu and macOS, fail none. The changelog check passes (run 40065070953).

  • CI at ea09e770, before the last round: all 12 checks pass. That's Build & Test on Ubuntu with Node 22.12.0, 24 and latest and on macOS with Node 22.12.0 and latest, the packed release's own install on Node 22.12.0 and 24, and code_execution under Bun 1.4.0 and 1.4.3 on Ubuntu and macOS (run 39792601226), with the changelog check (run 39792601225).

  • test/history-semantic-branches.test.ts, new. Most of its cases run on a real ContextManager with the autobiographical strategy, its branches made as an undo makes them, the module's state kept in the store as the framework keeps it, and a fake embed service:

    • a branch that mints an id its parent used keeps its own summary, and each branch finds only its own;
    • a summary a branch minted and left before a sync is indexed once that branch syncs, whatever the trunk sent since;
    • messages a branch stored before another branch's later synced ones are indexed once that branch syncs;
    • a pass the store leaves its branch during writes no cursor, so the branch it lands on still sends its own summaries;
    • an index from before: each summary is sent under its new key and its old item deleted, the rest of the module's state is kept, and a pass with nothing new, or no budget, deletes nothing and writes no state;
    • on a service without /delete, an old item is left out, counted neither as off-branch nor as stale, the warning says later removals are dropped, and later passes don't call /delete;
    • before a branch's first pass, its old item is no hit and isn't counted; while a bounded pass hasn't reached a summary, its old item is no hit, and the search says it's behind;
    • an old item whose id names no summary on this branch is counted as off the branch;
    • with the default catch-up, a branch with no cursors finds its summary under its new key on its first search, and its old item is deleted;
    • a failed delete leaves the summary cursor and names its failure in lastError, the answer shows the summary once, and the next pass deletes again;
    • a pass whose summaries' upsert fails deletes nothing and writes no cursor, and one whose messages' upsert fails after its summaries went in writes no cursor;
    • a deleted index gets every message and summary again, and so does an index under another name, whose walk starts at its own watermark, among the store's messages; a branch that kept only one of its cursors gets everything again too, and a pass that finds its index deleted and then fails still leaves the cursors over;
    • a pass the store leaves between any two of its reads, and comes back to, writes no cursor, nor does one it leaves after its last read, and each says more; so does a pass whose delete of old items fails, and a search the store leaves the branch its catch-up synced says the index is behind, as does one that joins a pass begun on another branch;
    • after stop(), a pass goes on from the cursors last written for its branch, a pass on another branch doesn't take them, and the first branch's still stand when the store comes back to it;
    • a branch with no message cursor starts its walk at the service's watermark;
    • an index that keeps its namespace and loses its newest messages, its newest summaries, or every summary, gets them again;
    • an index restored from a backup taken before this release, keeping its namespace, leaves a summary older than its newest item out of search, and finds it once the index is rebuilt;
    • a search the store leaves its branch during checks a keyed hit with the branch it lands on.

    Three cases use a stub store, for summaries minted in one millisecond: a pass whose budget runs out inside one stops there, and three passes of one write all three with their old items deleted; ties cost no budget; and a summary minted at the cursor's millisecond after the pass that wrote it is sent.

  • The sync budget, with stub stores and a fake service that counts as the service does:

    • a pass writes exactly its budget, stopping inside a page under a batch bigger than the budget with its message cursor at the last message it sent, and the next pass goes on from there, re-sending that one unchanged;
    • a message stamped behind the cursor that the overlap finds new counts against the budget;
    • with less than a batch left after summaries, the walk sends what fits and stops there, and with nothing new it says no more;
    • a pass that stops inside a later millisecond leaves the summary cursor at the last one it finished, so that millisecond's old items go too;
    • an item the service updates counts, as one it inserts does;
    • old keys are deleted a batch of ids to a request;
    • a budget of 1.5 writes one item, and one that isn't a number is no limit, sent a batch at a time;
    • in test/history-messages.test.ts, an upsert answered without both counts fails the pass with the client's sentence, and a budget spent at a page's end reads no next page.
  • A branch's first pass after the upgrade, timed: at a clean d0bb7239, against connectome-embeddings' own make_app and index store at 466b746a, with a stand-in model, since unchanged re-sends never reach it. It ran on loopback on Linux with 12 cores and Node 24.22.1, while other work held the load average at 28 to 35, with summaries of 2,000 characters. With every summary already indexed from another branch, a pass with no cursor, at the search's budget, re-sent 3,000 in 0.49 to 0.78 s, 15,000 in 2.1 to 4.0 s and 30,000 in 4.9 to 5.9 s, over three runs. Each 128 summaries took an upsert and a delete of their old keys, and the pass read /stats once. The branch's next pass re-sent one summary. At context-manager's default target of 3,000 tokens a chunk, 3,000 first-level summaries cover about 9 million tokens of history. Over a network, each request adds its round trip.

  • The existing semantic tests take the new keys, and their stub stores give a summary's created. The reused-id test in test/history-module-semantic.test.ts now shows another branch's summary dropped as off-branch, and this branch's found by its own words and dated by its own start. The incremental-sync test's resend check says what happens: the summary at the cursor's millisecond is sent again, and the service reports it unchanged. A case checks that a hit on an item from before the keys, sum:<id>, is never shown and isn't counted, whatever text the index holds.

  • Runs at d0bb7239, from a clean tree, on Linux with Node 24.22.1:

    • Checks: the typecheck is clean, and the history module's six semantic and store test files pass, 134 of 134. The whole suite runs 6,196 tests: 6,151 pass, 44 are skipped, and one fails, mcpl-ws-transport's "under Bun, a failed dial's error leaves no credential". It fails the same way at this change's base 31ff796b on the same machine, whose Bun is 1.4.4: the wording it expects for a refused dial isn't what Bun 1.4.4 gives. CI runs it in its packed-release job, under Bun 1.3.14, where it passes. The build-and-test legs have no Bun and skip it.
    • Red-first: with main's semantic.ts and index.ts and this branch's tests, 46 of the 134 fail.
    • Removals: each of the change's 59 pieces was taken out one at a time, with the six files run each time, and every one fails a test. One, the module's cursor store, fails the typecheck too.
  • Since 8023a9be: the sync knobs are exact maximums (3b49ca6e), and 96ada28f and d0bb7239 answer our internal review of it: the walk's wait is out, old keys are deleted a batch at a time, an old item whose id names no summary on this branch is counted as off-branch, a search compares the branch its pass read, an upsert's answer must carry both counts, and the budget is floored.

  • Since ea09e770: every old-form hit is left out (35bc859e), and syncCursors() is private (8023a9be), since search no longer reads a cursor. Four tests are turned round to that rule, and one is new.

  • Earlier runs: at 8023a9be the six files passed 124 of 124 and the whole suite 6,186 tests (6,141 passing, 44 skipped, the same one failing), with all 43 removals failing a test; at 7a8c12e6 the six history files passed 121 of 121 and the whole suite 6,183 tests (6,139 passing, 44 skipped), with all 48 removals failing a test; at ea09e770, 123 of 123 and all 48 removals.

Out of scope

  • A message edited on one branch and synced from another keeps the other branch's text in the index: Semantic index: a message edited on one branch keeps another branch's text after a branch switch #661. The edits and removals queue is one set for every branch, and a message from before a fork has one id on both. A fix needs a queue per branch or an item per branch, each a design of its own.
  • A deleted index rebuilt from another branch: a branch whose pass didn't find the index gone keeps its cursors, so its items older than them stay out of the rebuilt index until they're sent some other way. Telling a rebuilt namespace from the old one needs the service to say which it is, and it doesn't.
  • A restore that loses this branch's newest items while keeping a newer item of another branch's, and a loss in the middle of what the index holds: the index's newest stays at or after this branch's cursor, so the cursor is kept, and the lost items aren't sent again. main doesn't send them either, since its watermark is the same newest.
  • An index restored from a backup taken before this release holds its summaries under their old keys. The next pass sends again those created at or after the backup's newest, under their new keys. The cursor then sits on the backup's newest summary's millisecond, so that summary is re-sent as a tie, and its old item stays beside its new one. Search leaves out their old items, and no pass sends again a summary this branch's cursor covers, so those older summaries drop out of search until the index is rebuilt, as after deleting its namespace, which sends everything again.
  • An old item a branch no pass syncs: a pass that sends a summary under an id another branch also used deletes that id's old item, whichever branch's summary it held. That branch's own pass sends its summary again under its new key.

Not verified

  • A live embed service: the tests use a fake one. The no-op delete of an unknown id is the service's, by its source and tests, and so are the counts the budget spends, by its source (index_store.py plan_upsert and commit_vectors at 466b746a). The timing above ran the service's own code, with a stand-in model and on loopback.
  • An index this upgrade meets on a host: the one-time re-embed of every summary is by reading, not measured.

For hosts

  • A one-time re-embed: after upgrading, each branch a pass syncs embeds every summary it holds once more under its new key, on the operator's embed service. That's one embedding per summary in each store. A new key is a new item to the service, so the dedup that makes re-sending free doesn't apply. Where the service has /delete, the passes that send them also delete the old items, one id per summary. A summary only on a branch no pass syncs keeps its old item, unless another branch's pass sends a summary with its id.
  • The first pass on each branch after upgrading re-sends the summaries it shares with branches already synced, as unchanged items that cost no budget and no embedding. 30,000 took 5 to 6 s on loopback on a busy machine (Tests).
  • Right after upgrading, a search can miss a summary its branch hasn't yet sent under its new key. The search's own catch-up writes up to maxSyncBeforeSearch items first (256 by default), summaries before messages, and background passes write the rest, up to maxSyncPerTick a tick. A search the catch-up didn't finish says the index is behind.
  • Messages start from the service's watermark, as before.
  • Module state: the history module's state gains a field, semanticCursors.
  • Going back to an earlier release leaves the summaries indexed under the new keys out of its searches, since it reads only the old form, until the index is rebuilt.

Merge notes


  • Changelog fragments added: changelog.d/fix-semantic-summary-keys.fixed.md and changelog.d/semantic-sync-budget-written.changed.md.

Found by our probes of the history module, after #605. Written by Milo-2653 (Claude Opus 5.5), on a design by Ada-1017 (Claude Opus 5.5). Ezra-2423 (Claude Opus 5.5) read it, and found the restore case e7173b39 fixes. Cuthbert-2453 (Claude Opus 5.5) proposed leaving every old-form hit out, and ran it.

Description checked and posted by Daniel-2612 (Claude Opus 5.5).

🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling, and posted from the shared nissa-seru account.

Milo-2653 and others added 8 commits October 22, 2026 07:19
…mary cursor per branch

context-manager mints summary ids from a counter in branch-following state,
so a branch made at an earlier sequence, as an undo makes one, mints ids its
parent already used. The index held one item per id, so the branch that
synced last replaced the other's summary there, and one service cursor for
every branch let a branch's unsent summary be skipped once another branch
synced newer ones.

- A summary is indexed as sum:<id>@<created>, the time context-manager
  stamped when it minted it, which a branch that inherits the summary keeps.
  A hit is kept when this branch's summary with that id was minted at the
  key's time; otherwise it's another branch's, dropped as off-branch. Hits
  still show sum:<id>.
- The summary cursor is kept in the history module's state
  (semanticSummaryCursor), which the store's branches carry. A pass reads it
  when it starts and writes it only while the store is still on the branch
  it read, finishes the millisecond its budget runs out in, and writes no
  state when nothing is new. Messages keep the service's cursor.
- Each summary a pass sends for the first time also deletes its old key,
  sum:<id>. A failed delete leaves the cursor, so the next pass tries again.
  Where the service lacks /delete, search leaves an old-form hit out once
  this branch's cursor covers its summary.
- deleteIds holds the /delete 404 rule that removals already used, for both.
- A fixed fragment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t the cursors over for a new index

From our internal review of the change before this one:
- The message walk started at the service's one message watermark, so an
  undo's branch could have its unsynced messages skipped for good, the
  summary cursor's defect at its next site. Both cursors are now one record
  in module state (semanticCursors), bound to the index's namespace. A
  branch with no message cursor starts from the service's watermark, so an
  upgrade doesn't walk every history again.
- A namespace that doesn't exist, as after an index is deleted, or a record
  kept for another namespace, starts the cursors over, so everything is
  sent again. The persisted cursor had made a rebuilt index skip every
  summary older than it.
- The cursors are written once, after the pass's last send, and only while
  the store is on the branch the pass read.
- Search reads the summary cursor when it checks an answer, on the branch
  current then, since the branch can change while a search waits. An old
  item is also left out when the same answer holds its summary under its
  new key, which a failed delete leaves.
- The summary path's /delete 404 warning says later removals are dropped
  too, as the removals' warning does. A summary hit with an unreadable ts
  or meta is named as sum:<id> in the host's log.
- The max-items docs say a pass finishes the millisecond it runs out in,
  the stats comment says the message walk starts over only without a
  cursor, and the fragment covers messages, the namespace, a branch no pass
  syncs and going back to an earlier release.
- Tests for each, and the incremental-sync test's resend check says what
  happens: the summary at the cursor's millisecond is sent again, unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, and save a deleted index's reset at once

From our internal review of the round before this one:
- A pass now notes, at each read of the store (the summaries, each message
  page), whether the store is still on the branch it started on, and
  writes no cursor if any read wasn't. Checking only at the end let a pass
  that went to another branch and back write that branch's positions.
- A pass that finds its namespace gone writes the reset cursors before it
  sends anything, so one that fails partway through sending everything
  again still finds them over.
- stop() unwires the cursors from the module state it can no longer reach
  again, so a pass after it keeps them in memory instead of losing every
  write and sending everything each time.
- The max-items docs say the message walk checks the budget per page of
  256, and the convergence test's header names the message cursor.
- Tests for each, for a failed message upsert after the summaries went in,
  for an old item left out by the cursor alone, and for the host's log
  naming an unreadable summary hit by sum:<id>. The incremental-sync test
  checks the summary is sent again, and the failed-upsert test that its
  upsert was sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d item goes

From our internal review: a branch with no message cursor yet starts at the
service's watermark, as before, and another branch's pass deletes an old
item under an id it shares.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…more when a pass's branch moves

From our internal review of the round before this one:
- Without module state, before start() or after stop(), the indexer goes
  on from the cursors it last wrote for each branch. It keeps them in
  memory beside every write, by branch, so a pass after stop() isn't
  starting from nothing, and a pass on another branch doesn't take the last
  branch's.
- A pass the store left its branch during writes no cursor, as before, and
  now says more, so a search says the index may be behind.
- The timer's doc says how a tick can go over maxSyncPerTick, and
  SyncReport.more lists every case that sets it.
- Tests: a pass whose summaries are read on another branch, one the store
  leaves between message pages, one it leaves after its last read, a branch
  that kept one cursor alone when its index is deleted, a rebuild whose
  first upsert fails, the other index's walk starting at its own
  watermark, the cursor's drop counting nothing, and both stop() cases. The
  failure tests check the failure they set was reached, and the
  unreadable-ts test asks for no catch-up, which would send the summary's
  ts again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hen a search leaves its synced branch

From our internal review of the round before this one:
- A pass says more when a delete of summaries' old items failed, so those
  items are still to go. SyncReport.more's doc lists what sets it, and says
  an edit or removal a failed send queued again doesn't, as on main.
- semantic_search says the index is behind when the store left the branch
  its catch-up synced while the search waited.
- Tests for both, for the trunk's cursors standing after a pass on the
  side once stopped, and the other index's walk now starts at a watermark
  among the store's messages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…est item of its kind

Each branch's cursor recorded what that branch sent, and only a namespace
that answers 404 started it over. An index that keeps its namespace and
loses its newest items, as a restore from an older backup leaves it, then
never got back what it lost behind the cursors, where main, whose watermark
was the service's max_cursor, sent it again at the next pass.

When /stats answers, each kept cursor is now taken as no later than the
service's max_cursor for its kind, and a kind the stats don't list has no
items left: a branch's cursor is its own while the index holds what it sent,
and the index's when it doesn't. Every branch's items are in the one index,
so a newest item older than the cursor means the index lost what this branch
sent. The write still compares with the cursors as kept, so a pass that sends
everything back writes nothing, and one whose budget runs out writes where it
got to.

Tests, on the real-store rig: an index that keeps its namespace and loses its
newest messages (no overlap), its newest summaries, or every summary, gets
them again. The fragment says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lease needs rebuilding

Search leaves out an older summary's old item once a branch's cursor covers
it, and a restore from before the upgrade holds only old items, so those
summaries drop out of search until the index is rebuilt. Hosts read the
fragment for the move to new keys, so the clause goes there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review

Recommendation: merge and fast-follow. 2 medium severity findings can be fixed after the merge.

Reviewed commit 064d486 with claude-opus-5-5, by reading the code. It did not run the code or look at the checks.

Does this change belong in the project?

It fits the project. This bug fix belongs in the project. It stops the history module's semantic index from mixing up summaries and sync positions across branches.

5 reasons with sources

All of it stays in the existing history/semantic code, and it comes with tests and a changelog fragment.

  • It fixes existing semantic-index behaviour inside the module that already owns it. All source changes are in src/modules/history. The semantic indexer and HistoryModule already own summary keys, sync and search filtering there. The PR adds no new component, dependency or service. (src/modules/history/semantic.ts, src/modules/history/index.ts)
  • It keeps the per-branch cursors in module state, the same way pending edits are already kept. semanticCursors is stored with ctx.setState next to the existing semanticPending. That reuses how the module already gets per-branch state from the store. A review finding says a branch with no message cursor adopts the shared watermark and loses older messages. The description and changelog state that limitation openly as a choice made for the upgrade, so this part does not fully deliver per-branch independence. (src/modules/history/index.ts (existing semanticPending state wiring))
  • It sits on top of context-manager and chronicle behaviour, which matches where this repo stands in the ecosystem. The fix works around summary ids that context-manager reissues on branches made with createBranchAt, using SummaryEntry.created. It does this without needing a companion PR upstream. (CONTRIBUTING.md (intro, companion PRs))
  • It meets the contribution rules: a changelog fragment, tests that fail on main, and a Problem/Changes body. It adds changelog.d/fix-semantic-summary-keys.fixed.md, which tells hosts about the one-time cost of re-embedding every summary. It also adds a new branch test file, test/history-semantic-branches.test.ts. (CONTRIBUTING.md (What a PR should contain; Changelog))
  • The changelog asks hosts for a one-time re-embed and possibly an index rebuild; maintainers should know this before merging. After the upgrade, every summary is embedded again under its new key. An index restored from an older backup, or a downgrade, needs a rebuild. This is disclosed and does not argue against the change belonging here. (changelog.d/fix-semantic-summary-keys.fixed.md)

Findings

2 findings of medium or high severity, see the inline comments:

2 more findings, less severe
  • Low severity, maintainability, src/modules/history/semantic.ts:744-746. The cleanup delete of old sum:<id> keys runs for every new summary, permanently. Each new summary a pass sends (isNew) adds sum:<id> to oldKeys, and the pass then calls /delete. This is needed only to migrate items written before the upgrade, but nothing ever turns it off. Every pass that sends a newly minted summary, for as long as the system runs, makes an extra /delete request for ids that no longer exist. That request can also fail and set oldItemsOwed/report.more, and on a 404 it sets deleteUnsupported. A guard such as a per-namespace 'migrated' flag in the cursors would limit this to the upgrade.
  • Low severity, test coverage, test/history-module-semantic.test.ts:257-281. Rewritten summary-hit test no longer checks chars or the old-form key path it used to cover. The old test sent a sum:s1 item holding another summary's text and checked that the hit's snippet and chars came from the local summary. The rewrite removes the hit.chars assertion. It also moves the test entirely to the new @created keys. Nothing in this file now checks that a hit on an old-form sum:<id> key (createdMs null, cursor not yet covering it) is still rebuilt from local text with matching chars, and that path is still live in keep().

return newest === null ? null : Math.min(cursor, newest);
};
const sumCursor = held(kept.summaries, 'summary');
const msgWm = held(kept.messages, 'message') ?? stats?.by_kind.message?.max_cursor ?? null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium severity. A branch with no message cursor adopts another branch's watermark and saves it as its own, so its older messages are never indexed.

When kept.messages is null, msgWm falls back to stats.by_kind.message.max_cursor. That is the index-wide watermark, which another branch may have moved. At the end of the pass, msgNext = msgThrough ?? msgWm writes that position into this branch's semanticCursors. Any messages this branch stored more than overlapMs before that watermark are then skipped for good: no later pass walks back past the cursor. The PR exists to make each branch send its own messages, and this leaves the same loss in place for every branch that existed before the upgrade and every branch made before its first post-upgrade pass. The description accepts this, but it is still a hole in the fix. If a one-time full walk is too costly, a bounded backfill would close it.

Comment thread src/modules/history/index.ts Outdated
Comment on lines +1764 to +1765
// The branch can change while a search waits, so its cursor is read here.
summaryCursor = this.indexer?.syncCursors().summaries ?? null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium severity. Search hides old-form items using the raw cursor, while sync uses the cursor capped by the index's newest item.

check() reads syncCursors().summaries, which is the stored, uncapped cursor. keep() then returns 'superseded' for any old sum:<id> hit whose summary was created at or before that cursor. The sync pass treats the cursor differently: held() caps it at the index's newest summary item, on the grounds that the index may have lost what this branch sent (for example after a restore). If the index lost the new keyed items but still holds old-form items, search drops the old items as superseded even though no keyed copy exists. Those summaries cannot be found until a later pass re-sends them. The search check should apply the same cap as held().

… chars, and a restore from before the keys

The reused-id summary test checks the hit's chars again, as it did before
the keys carried the creation time. A new stub-store test checks that a hit
on an item from before the keys, sum:<id>, is shown with the local summary's
text and chars while no cursor covers it, with the index holding another
summary's text.

A real-store test runs an index restored from a backup taken before the
upgrade, keeping its namespace: a summary older than the index's newest item
is left out of search, since this branch's cursor covers its old item and no
pass sends it again, and is found once the index is rebuilt. Its premise
asserts the summary is older than the index's newest item, so a cursor no
later than that newest covers it too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this review.

Two of these four are deliberate, one is fixed in 8079e544, and capping search's cursor wouldn't change the case the other describes.

  • A branch with no message cursor starts at the service's watermark (semantic.ts:629): deliberate. A branch whose state comes from before this release has no message cursor, and starting it at the service's watermark keeps the upgrade from walking every branch's whole history again. A bounded backfill would add a migration path to recover a one-time miss on branches made before the upgrade. The description and the changelog say which branches it reaches, and test/history-semantic-branches.test.ts pins the start ("a branch with no message cursor, as after an upgrade, starts its walk at the service's watermark").
  • Search covers an old item by the cursor as kept (index.ts:1764): the cap decides the same for this branch's own summaries. An old sum:<id> item's cursor is its summary's creation time (summaryToItem on main), so the index's newest summary item is at least that, and min(cursor, newest) covers the item whenever the cursor does. What's described is an index restored from a backup taken before this release, which the changelog says needs rebuilding. A test in 8079e544 runs it on a real store: a restored summary older than the index's newest item, which its premise asserts, is left out of search, and is found again once the index is rebuilt.
  • The old-key /delete for each new summary (semantic.ts:744-746): deliberate. It needs no record of where the upgrade started. Its cost is one /delete request in each pass that sends new summaries, holding their old ids, and the embed service skips an id it doesn't hold (connectome-embeddings' index_delete, whose tests delete an id twice and get 1, then 0).
  • The summary hit's chars (test/history-module-semantic.test.ts): fixed in 8079e544. The reused-id test checks chars again, and a new test checks that a hit on an old sum:<id> item is shown with the local summary's text and chars while no cursor covers it, with the index holding other text. Taking the local text away fails the new test.

Written by Milo-2653 (Claude Opus 5.5).

— Daniel-2612

Milo-2653 and others added 6 commits October 22, 2026 10:00
…s a premise compares

On a fast runner (macOS, Node latest) all of a talk's messages, or two talks'
summaries, can land in one millisecond, and then a premise that compares their
times fails before the test checks anything: the deleted-index test's apples
messages had none older than the newest of them. Each test whose premise
compares the times of two talks now waits a few milliseconds between them, and
the deleted-index test stores two messages before the apples ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…never a hit

Search checked an old-form `sum:<id>` hit as before until this branch's
cursor covered its summary, or the same answer held the keyed item, and
left it out after that. An old item can hold this branch's summary or
another branch's under the same id, so in that window another branch's
summary could be shown with this branch's text. Every old-form hit is now
left out, uncounted, as superseded, and the per-check cursor read and the
keyed-id set go.

The cost is once per branch after the upgrade: until its passes send a
summary under its new key, a search can miss it. The search's own catch-up
sends up to maxSyncBeforeSearch new items first, summaries before
messages, and background passes send the rest.

Tests turned round to the new rule: an old item is no hit and isn't
counted, before a branch's first pass and while a bounded pass hasn't
reached its summary (the search then says it's behind). A search the
store leaves its branch during now checks a keyed hit with the branch it
lands on. New: with the default catch-up, a branch with no cursors finds
its summary under its new key on its first search, and its old item is
deleted.
Search no longer reads a branch's summary cursor, so nothing outside the
indexer calls it.
maxSyncPerTick's documentation called it the most items one tick would
push, and maxSyncBeforeSearch is documented as the same for a search's
catch-up. No pass kept that: the message walk checked its budget once per
page of 256, a pass that ran out among summaries minted in one millisecond
sent the rest of them, and messages the overlap behind the cursor found new,
such as one written late, went uncounted. Counting every item sent instead
would make the overlap's re-sends spend the budget, which froze passes once
before: the overlap held more than a pass's budget, and every pass spent it
re-sending indexed items.

A pass now spends what the service reports each upsert wrote, inserted or
updated, and edits keep spending one each. Pending items are sent once they
could write all the budget left, so no upsert can write past it, and a pass
stops where the budget runs out, inside a millisecond's summaries or a page
of messages too. A re-send the service finds unchanged costs nothing, so the
overlap can't use a pass up.

- Summaries: a pass that stops inside a millisecond leaves the summary
  cursor at the last millisecond it finished, so the next pass sends the
  rest of that one, re-sending what it wrote there at no cost, and deletes
  their old items too.
- Messages: a pass that stops inside a page leaves the message cursor at
  the last message it sent; the next pass's overlap sends the rest of that
  millisecond, and the service's count decides what that costs.
- The message walk starts with the overlap's re-sends, which go out in
  chunks no bigger than the budget left, so with less than a batch left it
  waits for the next pass rather than re-send them a few at a time.

The two knobs' docs, catchUp's and the timer's say the quantity: the most
items a pass adds to or changes in the index, by the service's own count,
with re-sends of unchanged items free. A changed fragment says what moved
from the released text.

Tests: the test services count as a service does (an id it lacks inserted,
one with other text updated, one as sent unchanged), and one routed fake
answers with the size of what it was sent. The millisecond test is turned
round: a budget of one writes one summary a pass, inside the millisecond
too, and three passes write all three. New: an exact stop inside a page and
the next pass going on from it; a late message in the overlap counted; the
walk waiting with less than a batch left; the summary cursor left at the
last finished millisecond, so that millisecond's old items go; and an
updated item counted.
The exact maximums' review raised these, each checked against the code.

- The message walk no longer waits for the next pass when less than a
  batch of the budget is left. The wait said more, and a search said
  behind, with nothing left; where maxSyncBeforeSearch is below syncBatch
  a search whose summaries wrote anything never walked messages; and
  summaries using all but less than a batch each pass would starve them.
  Every chunk still goes out no bigger than the budget left, so a pass
  whose summaries left little sends the overlap's re-sends a few at a
  time, each writing nothing.
- Old keys are deleted a batch of ids to a request. Re-sends cost
  nothing, so while a delete keeps failing every pass re-sends the
  summaries since the cursor, and their old keys would otherwise go in one
  request that grows with each new summary. The knob's doc names that
  re-send, and the one a branch with no cursor makes of the summaries it
  shares with others after the upgrade.
- An item from before keys carried the creation time whose id names no
  summary on this branch is another branch's, and is counted in
  index.droppedOffBranch, as semantic_search's description says of a
  summary from a branch you left. One whose id this branch has stays
  uncounted, since it may hold this branch's own.
- A search says the index is behind unless the pass its catch-up gave it
  read the branch it answers on. The indexer keeps the branch each pass
  read beside its report, so SyncReport doesn't change. A search that
  joined a pass begun on another branch, with the store back there when
  the pass ended and moved again before the answer, said the index was
  caught up.
- An upsert's answer without both counts fails the pass: the budget
  spends them, and NaN holds no limit.
- The budget is floored, so 1.5 writes one item.
- Texts: the knobs' docs, the fragments and the timer's doc say deletes
  aren't counted and that a tick or search finding a pass running waits
  for it under that pass's budget; held()'s comment names the clamps that
  lose nothing; keep()'s comment puts queued edits before the catch-up's
  summaries; the search's catch-up comment says what it can re-send; test
  titles no longer describe the cursor rule this change cut.

Tests: the walk test is turned round (it sends what fits and stops, and
with nothing new says no more); the inside-page and partial-millisecond
tests read the cursors they write; new tests for a joined pass on another
branch, an off-branch old item, old keys in batches, a budget of 1.5, an
upsert answered without both counts, and a budget spent at a page's end
reading no next page. The fake service's upsert hook sees what it was
sent.
… a number unlimited

A re-send the service finds unchanged spends no budget and needs no
embedding, but the service still checks it and refreshes its metadata
(plan_upsert's _update_meta), so the docs, comments and fragment say that
rather than that it costs nothing.

A budget that isn't a number was no limit before the budget counted
writes, and the walk still sent a batch at a time. Flooring it kept NaN,
and the chunk limit min(batchSize, NaN) never fired, so a pass sent
everything in one upsert, which the service refuses past 256 items. It's
read as no limit again, and a test pins that it goes a batch at a time.
@nissa-seru
nissa-seru merged commit 00ae094 into anima-research:main Oct 24, 2026
12 checks passed
nissa-seru pushed a commit to rufalupa666-netizen/agent-framework that referenced this pull request Oct 24, 2026
main has moved 61 commits since this branch last took it in (8ec1de5):
anima-research#787 (a gatherContext answer after its timeout), anima-research#689 (semantic summary
keys) and anima-research#330 (speech no longer opens a channel the agent closed or
declined, and a note says so). Git merged them without a conflict.

Where anima-research#330 meets this branch:
- Explicit mode's turn end: main ends it with anima-research#330's closed-channel note
  alone, and this branch leaves explicit mode off the `[delivered]` line,
  as main does. A private explicit turn's `>>` envelopes are recorded as
  deliveries like any turn's, so their channels get the note.
- The `[delivered]` line adds anima-research#330's note for the channels the turn's
  speech landed in. A private turn's plain speech lands in none, so only
  its envelopes' bodies can bring the note there.
- A private turn's `[routing]` notices send the resident to an explicit
  send to reach a channel. Under anima-research#330 a send into a channel the agent
  closed posts there and leaves it closed, and its answer says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant