Repository navigation
fix(history/semantic): key summaries by creation time, and keep the sync cursors per branch - #689
Conversation
…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>
There was a problem hiding this comment.
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.
semanticCursorsis stored with ctx.setState next to the existingsemanticPending. 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:
- 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.
- Medium severity. Search hides old-form items using the raw cursor, while sync uses the cursor capped by the index's newest item.
2 more findings, less severe
- Low severity, maintainability,
src/modules/history/semantic.ts:744-746. The cleanup delete of oldsum:<id>keys runs for every new summary, permanently. Each new summary a pass sends (isNew) addssum:<id>tooldKeys, 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 setoldItemsOwed/report.more, and on a 404 it setsdeleteUnsupported. 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 checkscharsor the old-form key path it used to cover. The old test sent asum:s1item holding another summary's text and checked that the hit's snippet andcharscame from the local summary. The rewrite removes thehit.charsassertion. It also moves the test entirely to the new@createdkeys. Nothing in this file now checks that a hit on an old-formsum:<id>key (createdMs null, cursor not yet covering it) is still rebuilt from local text with matchingchars, and that path is still live inkeep().
| 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; |
There was a problem hiding this comment.
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.
| // The branch can change while a search waits, so its cursor is read here. | ||
| summaryCursor = this.indexer?.syncCursors().summaries ?? null; |
There was a problem hiding this comment.
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>
|
Replying to this review. Two of these four are deliberate, one is fixed in
Written by Milo-2653 (Claude Opus 5.5). — Daniel-2612 |
…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.
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>
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/undomakes one withcreateBranchAt, 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
ContextManagerwith the autobiographical strategy, context-manager 0.14.1 and chronicle 0.4.0, the realHistoryModule, and a fake embed service), and this change's tests show the third, red onmain:sum:<id>, so the branch that synced last replaced the other branch's summary.L1-2was about bananas, an undo's branch minted its ownL1-2about cherries, and after the branch synced, the index'ssum:L1-2held cherries.sum:L1-2.max_cursorfor 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.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
sum:<id>@<created>, the time context-manager stamped when it minted the summary (SummaryEntry.created, whichgetSummariesInRangegives ascreatedMs). 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.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 inindex.droppedOffBranch. Hits still showsum:<id>, sosemantic_search's andextract's texts don't change.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./statsanswers, 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, asmain'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.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).index_delete, whose tests pin a second delete as 0).index.droppedOffBranch, assemantic_search's description says of a summary from a branch you left.maxSyncBeforeSearchitems first, after any queued edits, summaries before messages./delete, the old items stay, and search leaves them out. The warning says later removals are dropped too, as the removals' warning does.deleteIdsholds the rule that a/delete404 in a pass whose/statsanswered with stats means a service without the route. Removals and old summary items both use it.start()wires the module's state, and afterstop()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.tsormetacan't be read assum:<id>, as the hit is shown.maxSyncPerTickandmaxSyncBeforeSearch, is now an exact maximum of what a pass writes:changedfragment 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.fixedfragment, and achangedone 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
maxCharsdoes, and then every long summary's hits would be dropped, with nothing resending them (Ada-1017's call).sum:<id>: the tool texts describe hits by that id, andextractrefuses asum:id by its prefix. A changed id would change what the model reads.main, whose watermark is the index's newest, sends it.stop(), so the first pass after it would send every summary again.sum:<id>: that's the id the hit is shown by, so a host reading the log can find it.bind()andstart(), or has unwired, still keeps its cursors in memory. Without them, each of its passes would send every summary again, a request per batch.morewhen 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 leavesmoreasmainhas it.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.>=) 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.plan_upsert, which gives an item it holds with the same text a metadata refresh only), which is the work the knobs pace.semantic_search's description, which this change leaves as it is, says a summary from a branch you left is dropped and counted inindex.droppedOffBranch(Ada-1017's call).SyncReportdoesn't change.NaN, and no stop or chunk limit holds underNaN.syncCursors()private: search no longer reads a cursor, so nothing outside the indexer does.threeTopicsandloseNewest(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,stubModuleandkeysOf(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./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 out46418bd, GitHub's merge ofd0bb7239intomain95c65565. 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 out9caeeb6, GitHub's merge of8023a9beintomain8ec1de5a. 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 realContextManagerwith 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:/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;lastError, the answer shows the summary once, and the next pass deletes again;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;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;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:
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' ownmake_appand index store at466b746a, 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/statsonce. 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 intest/history-module-semantic.test.tsnow 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:mcpl-ws-transport's "under Bun, a failed dial's error leaves no credential". It fails the same way at this change's base31ff796bon 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.main'ssemantic.tsandindex.tsand this branch's tests, 46 of the 134 fail.Since
8023a9be: the sync knobs are exact maximums (3b49ca6e), and96ada28fandd0bb7239answer 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), andsyncCursors()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
8023a9bethe 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; at7a8c12e6the 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; atea09e770, 123 of 123 and all 48 removals.Out of scope
maindoesn't send them either, since its watermark is the same newest.Not verified
index_store.pyplan_upsertandcommit_vectorsat466b746a). The timing above ran the service's own code, with a stand-in model and on loopback.For hosts
/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.maxSyncBeforeSearchitems first (256 by default), summaries before messages, and background passes write the rest, up tomaxSyncPerTicka tick. A search the catch-up didn't finish says the index is behind.semanticCursors.Merge notes
main95c65565, which carries 0.26.2's changelog, fix(injection): a message the render gives null goes in as stored #720 and fix(modules): a gatherContext answer after its timeout adds nothing; a throw skips one module #787.mainthan ontomainalone (git merge-treeof each head, 08:07Z).messageToItemleaves stored context injections out of the index. It sits beside this change's keys and cursors without conflicting with them. It conflicts withmaininsrc/module-registry.ts, the same with or without this change.mainalready, in the same number of regions either way.changelog.d/fix-semantic-summary-keys.fixed.mdandchangelog.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
e7173b39fixes. 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-seruaccount.