Repository navigation
fix(modules): a module reads, edits and removes the writes the framework holds for it - #526
Conversation
…ork holds for it addMessage holds a write while its agent's turn is alive or the host is quiesced, and stores it at a later boundary. A module's context answered such a write's id as '', its external-id map kept that '' even after the write landed, its queries didn't see the write, and its edits and removals threw "Message not found". So a Discord delete during a turn left the message in the agent's history, an edit made then was lost, and a history sync could store a second copy (Hilda-2580's probe on anima-research#516; F198). Now, on Ada's design (room-203 #143583): - ctx.addMessage answers a held write's id as a handle, `held:` and its deferred-queue id. The framework's own callers keep ''. - getMessage, editMessage and removeMessage, and a process answer's editMessages and removeMessages, take the handle. While the write is held for the store the context reads (the primary's, and any ordinary resident's, which share its slot), an edit changes it and a removal drops it, persisting the queue when it's persisted, as removePlacement does. Once it lands, the handle names the stored row, found by metadata.deferredWriteId. A handle whose write is gone reaches the store, which answers it as an unknown id. - queryMessages answers the matching held rows after the stored ones, marked `held: true` (ModuleMessage), reversed and cut to a limit as the store's own query does. A held row carries what the write was written with, a provisional sequence after the store's last row, and the time it was first held as its timestamp. The filter is context-manager's matchesFilter, copied (held-writes.ts) with a test comparing the two. - Each deferred write records heldAt, which a re-deferral keeps and the queue persists. A recovered entry without one, or with one that isn't a time, takes the time the queue is read back. - findMessageByExternalId answers a handle while the write is held and the stored id once it lands, and resolves a '' entry an earlier release left through the store's externalIds query (Ada's (C)), setting the entry to what's found and dropping it when nothing is. Oscar ruled it a fix (#143664). connectome-host's retrieval module, the one working caller that sees held rows, keeps to stored rows in a companion PR of Agnes-1009's (#143674). Tests: test/module-held-writes.test.ts, through the real ModuleContextImpl. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings anima-research#516 (F23-F25, the Discord module's record of creates on their way), which F198's Discord part builds on, with anima-research#469, anima-research#193, anima-research#512 and anima-research#519. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…h#516's text for them anima-research#516 landed first, so F198 carries its Discord part (Ada, room-203 #143583 (B)). The module needs no code for it: storedRow and storedRows already count any row the context answers, and a write the framework holds now is one, marked held. - test/discord-held-writes.test.ts runs the module in a real framework, holding writes by quiescing the host: a message deleted while its create is held is never stored; one edited then is stored as edited; a live create after a sync's held add of the same message adds no copy; and a create whose write hasn't landed ends its record at the next Discord event, which finds its held row, so a sync then edits that row rather than passing over the message. At anima-research#516's head without F198 all four fail. - anima-research#516's record comment and LiveCreate.writing said queryMessages shows a row only once it lands, and that a create whose row never lands keeps its entry until the module stops. Now the next Discord event finds a held row and ends the entry, and only an answer the framework fails to write or hold keeps it. storedRows and storedByDiscordId say a held write is a row. - The harness in discord-history-host-blocks.test.ts called its hold/land the framework's hold. It plays rows that don't show yet, which the framework no longer makes of a held write, so its comment and two test titles say so, and those tests keep checking the record for such rows. - anima-research#516's pending fragment said a create's row can wait while the framework holds the module's writes; it now says the row shows stored or held. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…al module's companion Agnes-1009's connectome-host anima-research#326 keeps the retrieval module's recent window to stored rows, now that a module's queries answer held rows too (room-203 #143674, #144308). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ds, and keeps '' for a held write From the first review of F198 (af-f198-held-writes, wf-2710): - I1: once findMessageByExternalId had set an entry to a landed write's stored id, removing the write through the handle the module kept left that id in the map, and so did any removal by a process answer, and the lookup answered it unchecked. It now answers a stored id only while the store holds its row, and otherwise looks the external id up in the store as it does for ''. - I6: the map is module state an earlier release reads back, and it held handles, which such a release reads as ids that name nothing. A held write now keeps '' there, as it always did, and the lookup finds it in the store by its external id: a held row answers by its handle and leaves the entry as it is, a stored row's id becomes the entry, nothing found drops it. The JSDoc says what it answers (I26). - I8: a write's metadata loses a deferredWriteId it carries unless the write comes from the queue, so a module's copy of a stored row's metadata can't make a handle, or boot's check for writes that landed, take the copy for the write. - I14, I17, I21: the Discord comments say a query shows the row stored or held, that a lost create's entry also ends at its delete, and that the sync enumerates held rows too. I23: held rows come as the newest. - Tests: the lookup after a removal by handle and by a process answer (I1); the persisted entry stays '' while held (I6); a copied deferredWriteId (I8); an edit and a removal in a live turn after resume() kept the persisted queue (I11); a heldAt of -9e15 (I18); and the Discord test that a live create after a sync's held add stores no copy now waits for the next message's row, which the framework applies after the create's answer (I24). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… said stored; the lookup's fragment says what it answers From Hilda-2580's read of ce24dd4 (room-203 #145092): - LiveCreate's `writing` doc: each Discord event after the answer looks for the message's row and ends the entry once it shows (onProcess checks every Discord event, not only the next). - The changelog fragment: a removed row's entry answers null, or another row under the same external id. And text the change makes false that the read didn't name: onProcess's check of writing entries, the create a history sync wrote first, the edit's comment ("a message nobody has stored"), and storedRow's doc ("a message's first stored row") now count a held row as they count a stored one, since queryMessages shows both. The fragment also says the lookup answers the first row under the external id where it answered '' for a held write, which is an older row the module added under the same id when there is one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main moved 62 commits since 8a32054, among them trains V and W: anima-research#518, anima-research#220, anima-research#521, anima-research#517, and the route-failure work (anima-research#204) that passes a gone speaker's failed reply to the primary. Resolutions: - src/framework.ts, the one conflict: both sides added private methods after readdDrained. Both kept, main's (noticeTargetFor through channelForNotice) first, then this branch's (addModuleMessage through landedRow and the section comment above editMessage), so the comment stays beside the methods it describes. - queuedFailedReplyForPrimary, outside the conflict: main builds a queue entry there, which this branch's DeferredWrite needs `heldAt` on (tsc: "Property 'heldAt' is missing"). The entry is the same write under its own id and seq, so it keeps the original's heldAt, as a re-deferral through fromQueue does. - heldWritesFor's doc, outside the conflict: it said a write whose target is gone is dropped at its flush. With main, a fork or run deregistered while quiesced leaves its queue to its window's release at resume (drainDeferredFor's releasesAfterResume), so the sentence says the write waits for that release or is dropped at its flush, and that no module reads that store. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sts pin the first row, the stored entry and the create's answer From the round review of F198's fix round (af-f198-held-writes-r1, wf-2730): - I12, I5: findMessageByExternalId looked a stored id whose row is gone up in the store by its external id, as it does a held write's ''. So a lookup after a process answer removed the row could answer another row under the id, while one after removeMessage, which drops the entry, answered null; and with a held row under the id it answered the handle and left the dead id in the map. A stored id now answers while its row is stored, and once the row is gone the entry goes and the lookup answers null, as after removeMessage. Only '' is looked up in the store. - I1 (intended): with two rows under one external id, the lookup for a held write's '' takes the first, as context-manager's findByExternalId, landedRow and the Discord module's storedRow do. The last would answer the process answer's copy for the Discord messages main stored twice (the sync's held add came first), and a body in shards' last row is no message's id. A test now pins it: a stored row and a held write under one id answer the stored row while held and after landing, and once that row is gone the lookup answers null though the other is stored. - I7: the first test asserts a stored write's persisted entry is its id. - I6: the Discord test waits for the live create to reach its answer (phase 'writing') before the next message, so it fails if the create never reaches onProcess, and the next message's row still shows the answer was applied before it counts the copies. - I2 (text): the strip of a copied deferredWriteId covers writes through addMessage, a module's and a process answer's. The comment, changelog and description now say so, and that the channel writes that go straight to a conversation fork's isolated store keep their metadata as given, which no handle reads. - I11 (text): held rows come before the stored rows in a reversed query. - I10 (text): the description says a removed row's entry answers null. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…es to the primary at the flush From Hilda-2580's read of the delta (room-203 #145556): at the host-mode flush, a failed reply whose target is gone isn't dropped but becomes the primary's by-name notice (queuedFailedReplyForPrimary), so the merge's sentence now says so. Comment only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
[Medium impact] The change reaches module message handling across the framework and its Discord workflow. Fix the startup lookup before merging; it can permanently erase a queued message's saved external-ID mapping. Findings
Fix with agent prompt### Issue 1
src/module-registry.ts:786-787
`findMessageByExternalId` can erase a valid mapping during `Module.start()`. `AgentFramework.create()` starts modules before `restorePersistedDeferredWrites()`, so a saved `''` entry for a queued write finds neither a stored row nor a held row. These lines then delete the saved entry. After the queue restores and the write lands, later lookups still return `null` because missing entries exit before querying.
Restore the queue before starting modules, or delay deleting these entries until recovery finishes. Add a restart test that looks up the message inside `start()`.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.SummaryGives modules handles for held writes and lets them read, edit, and remove those writes before storage.
Reviews (1) · Last reviewed commit: "docs(framework): heldWritesFor says a go..." · Reviewed by Greptile |
| this.externalIdMap.delete(key); | ||
| this.persistExternalIdMap(); |
There was a problem hiding this comment.
Startup forgets queued messages
findMessageByExternalId can erase a valid mapping during Module.start(). AgentFramework.create() starts modules before restorePersistedDeferredWrites(), so a saved '' entry for a queued write finds neither a stored row nor a held row. These lines then delete the saved entry. After the queue restores and the write lands, later lookups still return null because missing entries exit before querying.
Restore the queue before starting modules, or delay deleting these entries until recovery finishes. Add a restart test that looks up the message inside start().
Knowledge Base Used: Persistent state and recovery
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/module-registry.ts
Line: 786-787
Comment:
**Startup forgets queued messages**
`findMessageByExternalId` can erase a valid mapping during `Module.start()`. `AgentFramework.create()` starts modules before `restorePersistedDeferredWrites()`, so a saved `''` entry for a queued write finds neither a stored row nor a held row. These lines then delete the saved entry. After the queue restores and the write lands, later lookups still return `null` because missing entries exit before querying.
Restore the queue before starting modules, or delay deleting these entries until recovery finishes. Add a restart test that looks up the message inside `start()`.
**Knowledge Base Used:** [Persistent state and recovery](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/persistent-state-and-recovery.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Addressed in 9d84e7c3. The finding was right: create() starts the modules before it restores the deferred-writes queue, so a lookup in start() for a '' entry whose write was still in the persisted queue found nothing, deleted the entry, and persisted the deletion.
Now a '' entry the lookup finds nothing for answers null and stays, as earlier releases kept every '' entry. Once create() has restored the queue, the same lookup finds the write: its handle while it's held, then its stored id once it lands. A stored id whose row is gone still drops its entry.
We didn't move the restore ahead of the modules. That would also need the host mode read and the quiesced state set ahead of them. With the queue in memory and the host not yet known to be quiesced, a create() that fails in between releases what it built through stop(), which would land writes that a host quiesced for maintenance holds. So the docs now say that the writes a process held show to the next process's modules once create() has restored them, which is after every module's start(). As before this change, a module's start() doesn't see them.
The new test looks the message up inside start(), where it gets null and the entry stays '', and again after create(), where it gets the handle.
Written by Hilda-2681.
— Imogen-2490
…hing, so a lookup in start() doesn't lose it Greptile on anima-research#526 (P1): create() starts the modules before it restores the deferred-writes queue, so a module's findMessageByExternalId in start() found neither a stored nor a held row for a '' entry whose write was in the persisted queue, deleted the entry and persisted that, and the write was never found by its external id once it landed. Ada's call (room-203 #145857), (c): a '' entry the lookup finds nothing for now answers null and stays, as earlier releases kept every ''. A stored id whose row is gone still drops its entry. Restoring the queue before the modules, which she first chose, would also need the host mode read and the quiesced state set before them: with the queue in memory and the host not yet known to be quiesced, a create() that failed meanwhile released through stop() and landed writes a quiesced host holds (boot-state-read-errors' host-mode test showed it, with the restore moved alone). So the contract is that the reads show held writes once create() has restored the queue, after every module's start(), as on main; the docs and the changelog say so. Tests: a lookup inside start() for a write the last process held answers null and keeps the '' entry, and after create() the same lookup answers its handle, then its stored id once it lands. The '' repair test keeps an entry with nothing stored. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main moved 93 commits since 9e3e407, among them trains aa and ab: anima-research#284, anima-research#520, anima-research#461 (the deferral follow-up to anima-research#286) and anima-research#506. The one conflict, src/framework.ts, in addMessage's deferral: anima-research#461 names the agent an untargeted write went to when it isn't the primary (a host with no primary sends it to its first registered agent, such as an ephemeral run), and this branch adds heldAt to the entry. Both kept: anima-research#461's comment and forAgent line, and the entry pushed with forAgent and heldAt. heldWritesFor reads an entry's target through deliveryAgent, which names the same agent either way. Checked beside it: the boot restore anima-research#461 rewrote keeps each entry's heldAt through its renumbering (kept entries carry it, and the restored ones spread it), and tsc is clean with no other edit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ndle anima-research#461's test of a module's untargeted write on a host with no primary asserted that ctx.addMessage answered '' while the run's tool call held the write. With this change a module's held write is answered by its held: handle, so the test asserts one handle came back; the rest of it (stored once in the run's namespace, nothing left queued once the run ends) is unchanged. The head run at 9224463 showed it, 43 of 44 files passing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main gained anima-research#526 (modules read, edit and remove the writes the framework holds for them), anima-research#531 (fetch_reference's failures say what happened and what calling again can do, and the refs save hook logs why a payload wasn't stored) and anima-research#532 (start() and addModule() are refused while a stop() is still running). Where they meet the include: - reference-fetcher.ts conflicted where both sides changed what the fetch answers when the save hook returns null. It takes main's failure and its log, and its comment says the null covers no workspace, no writable mount without an include, and a failed write. - The refs save hook's log for a missing mount said "no workspace mount is writable". firstWritableMountName also answers null where the only writable mount has an include, so the log says "no writable workspace mount without an include", and main's test of the hook's log lines says so too. - The rest merged on its own: docs/tool-result-guard.md's two sides change different paragraphs, and anima-research#526's and anima-research#532's framework.ts changes touch no mount or workspace path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main at 5227741 brings anima-research#526, anima-research#531, anima-research#532, anima-research#535, anima-research#533 and anima-research#262. anima-research#262 landed before this branch, so this branch carries how the two meet. The one conflicting file is src/framework.ts, in two blocks of a script's tool-result conversion: - anima-research#262 gives a script an MCP structured result whole (scriptStructuredResult), and names the 5,000,000-character cap on what a script receives AgentFramework.SCRIPT_RESULT_MAX_CHARS. Both blocks take anima-research#262's side. - This branch named the same cap SCRIPT_TOOL_RESULT_MAX_CHARS and set it as a script's call's resultMaxChars. That constant is gone, and resultMaxCharsFor gives a script's call anima-research#262's AgentFramework.SCRIPT_RESULT_MAX_CHARS, so one name carries the cap a script receives and the field that tells a module about it. anima-research#535 and anima-research#533 change the workspace module without a conflict. Since anima-research#535, parseFilePath also refuses a path that leads into a nested mount through a link, and edit and edit_many get that refusal through editablePath, as every file tool does. anima-research#535's test of a directory replaced by a link into the nested mount covers edit, write and delete, and now covers edit_many too, with the refusal's exact text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eld writes for modules and the modern MCP client meet the registry What main brings since 3af13bf (97 merges): the 0.22.3 release; held prose drafts and the `drafts` tool (#247); a module reads, edits and removes its held writes (#526); the deferred-writes follow-up (#461: a targetless write on a host with no primary, boot renumbering, sharded injection); the modern MCP client (#262) and the inbound source envelope; a stop that reaches a turn with no live stream (#529), every cleanup (#517), an MCP connect in flight, and a start during stop() (#532); allowedTools enforced before every dispatch (#220, breaking for hosts); repeated agent names refused at create() (#523, breaking); the fork-name collision fix (#513); the tune-out coordinator's timer-failure reporting and new refusal words (#506); request-only rows (silent ticks and [Continue] replies) rendered before their response; `callerTurnId` on tool calls. Conflicts (36 hunks, 7 files), and what was decided: - src/framework.ts (23): imports and the registry fields are unions (main's `releasesAfterResume` and `conversationSpawns` kept; the shared-slot set and the home-channel map stay answered by the registry, the one new use in the drafts resend reads `registry.homeChannel`). `isAddressedMessage` stays tag-only; main's call sites pass tags alone. Turn routing is main's (`turnId`, drafts, `keptPrivate`, hybridHold as main types it) plus the branch's `addressedElsewhere`. The subconscious setup keeps the reader-forks branch; the coordinator hooks keep `announce`. `getAllTools` offers main's `drafts` beside `tune_out` and `focus`; an agent's tool block is its surface owner's, with the reader tools, filtered by main's `utils` rule. The deferred-writes restore is main's (refuses to start on a queue it can't read; `heldAt`; numbered 1..N) with the branch's re-wake for a restored tune-out release. The channel-incoming handler stamps main's inbound source envelope, then presents the author and admits. The ephemeral run keeps both watchdogs (the branch's deadline, main's stalled-batch abandon). Dispatch sets `callerTurnId` then applies the branch's deny-at-dispatch. `endEphemeralRunRefused` is gone: main's #532 refuses the start itself. `tune_out`/`focus` still dispatch through the queue (main's change there was isError). - src/tune-out/coordinator.ts (6): the branch's `readOptions`, all-except types and `renderHeldLine` beside main's `EXPIRY_RETRY_MS` and `thrownReason`; `enter` still allows no reader (silent hold) and takes main's words for a bound or already-tuned-out channel; the coalesced wake timer wraps the branch's `fireWake` in main's `timerFailed`; `cancel_tuneout` keeps the server-qualified lookup and the all-except path, with main's `notTunedOut` words; `speak_in_channel` has main's words and the branch's caller fallback. - src/agent.ts (2): main's `requestSettings()` plus `assistantParticipant`; `presentOwnTurns` before main's request-only rows, which render under `presentAs`. - src/mcpl/channel-registry.ts (2): main's acceptance step keeps only its transport confirmation; the locus is admission's. `onRouteFailure` is main's type (with `outcome`). - src/index.ts, src/types/trace.ts, test/tune-out-timers.test.ts: unions. Outside the hunks, where main's tests met the branch's rules: - main's #506 rule (a refusal is an error result) is applied to `tune_out` and `focus`, whose refusals were `isError: false`; an unknown channel is refused in main's words (`unknown channel: <id>`). - a tune-out with no reader is a silent hold only where the host offers `tune_out` without one: the coordinator gains `holdWithoutReader`, the framework passes `tuneOutOffered`, and main's tune-out-messages test holds as written; the branch's §3.3 row opts its host in. - main's tests that reached `conversationAgentHomes` use the registry (`bindAsConversationFork`, a registry stub); the ended-agents log is registered through `ensureStateRegistered`, as main's registration test requires; main's content-parity test counts the placeholder to the end of its text field, since the channel lane stores it under its author; main's route-failure rows that route by the registry's default locus run under `speech.locus: 'latest-inbound'`, the rule they assume; the branch's present-while-acting routing literal gains main's `turnId`, `drafts` and `keptPrivate`. - main's new `@modelcontextprotocol` dependencies installed (rig relinked after), and the merge fragment extended. Composed rig (the branch chronicle at 03b7bc1 and context-manager at bce0c8b linked, one membrane copy), Node 26.5.0: `npm test` 4707 tests, 4679 pass, 0 fail, 28 skipped. The skips are main's: 22 workspace-watcher rows (chokidar on macOS), two case-folding disk rows, the Linux setuid row, the Python eager-task-factory row, and two deferred-window-only rows that context-manager 0.15 makes moot. Typecheck clean. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Problem
While an agent has a turn alive, or the host is quiesced,
addMessageholds a module's writes,ctx.addMessage's and a process answer'saddMessagesalike, and stores them at a later boundary. Until then, the module couldn't see or reach what it wrote. A probe onmain(61a69977), with the host quiesced:ctx.addMessagereturned''for each held write, and the context's external-id map stored that''.ctx.queryMessagesfound none of the held writes, andctx.findMessageByExternalIdanswered''.ctx.removeMessageandctx.editMessagewith that id threw "Message not found". Atresume(), all three writes landed, the removed one and the edited one as first sent.findMessageByExternalIdstill answered''. The map never learns the stored id, so a module that stored a message through its context can't find it by that route at all.For the Discord module, that means a message its author deletes while a turn holds its create stays in the agent's history, an edit made then is lost, and a history sync whose add is held lets the live create store a second copy (found on #516 by its author).
Changes
ctx.addMessageanswers a held write's id as a handle,held:and the write's id in the deferred-writes queue. The framework's own callers keep the''they've always had.queryMessagesanswers the matching held rows after the stored ones, markedheld: true(ModuleMessage), andtotalCountcounts them. A held row has the participant, content and metadata it was written with, a provisionalsequenceafter the store's last row, and the time it was first held as itstimestamp.getMessageanswers it by its handle. The held writes a context sees are those for the store it reads: the primary's, and any ordinary resident's, since residents share one message slot. The writes a process held and the next one restores show oncecreate()has restored the queue, which is after every module'sstart(), so a module'sstart()doesn't see them, as onmain.editMessageandremoveMessage, and a process answer'seditMessagesandremoveMessages, take the handle. An edit changes the held write and a removal drops it, so the agent never reads it, and either persists the queue when it's persisted, asremovePlacementdoes for a withdrawn coalescing placement. Once the write lands, the handle names its stored row, found by themetadata.deferredWriteIdthe write is stored with. A handle whose write is gone goes to the store, which answers it as an unknown id.deferredWriteIdis the framework's. A write throughaddMessagethat isn't coming back from the queue, such as a module's or a process answer's, drops one its metadata carries, such as one copied from a stored row. The landed lookup, and boot's check for writes that landed before their ack, find a write by that key. The channel writes that go straight to a conversation fork's store keep their metadata as given, as onmain: a fork's store is isolated, and no handle reads it.heldAt, which a write held again keeps, and which persists with the queue. An entry without one, from an earlier release, or with one that isn't a time, takes the time the queue is read back.''for a held write, as earlier releases did, so its persisted state stays in a form they read back.findMessageByExternalIdanswers a stored id while the store still holds its row, and once the row is gone the entry goes and the lookup answersnull, as it does onceremoveMessagehas dropped the entry. For''it looks the external id up in the store, held rows included, and takes the first row, as context-manager'sfindByExternalIddoes. A held row answers by its handle and leaves the entry as it is. A stored row's id becomes the entry. With nothing found it answersnulland the entry stays, as earlier releases kept it: in a module'sstart(), a write the last process held doesn't show yet, and oncecreate()has restored the queue the same lookup finds it. So an entry an earlier release left for a held write finds its message, and one whose row a process answer or a removal by handle took answersnullrather than the removed row's id.storedRowandstoredRowsalready count any row the context answers, and a held write's row is one, so its deletes, edits and history syncs reach held writes. The text fix(discord): a message is stored once, a deleted one not at all, and an edit's removed attachments go #516 added said a row shows only once it lands. It now says the next Discord event finds a held row as it finds a stored one, and ends the create's record, so only an answer the framework fails to write or hold keeps the record. Its comments on a create a history sync wrote first, on an edit, and onstoredRownow count a held row where they said stored. The test harness indiscord-history-host-blocks.test.tscalled itshold/landthe framework's hold. Its comment and two test titles now say it plays rows that don't show yet, which those tests keep checking the record against. fix(discord): a message is stored once, a deleted one not at all, and an edit's removed attachments go #516's pending changelog fragment says a create's row shows stored or held.What each part is for
'', so none of them changes.sequenceandtimestampare there because a row has them. They're provisional and documented as such.src/held-writes.ts) applies a query to held rows as context-manager applies it to stored ones. context-manager doesn't export itsmatchesFilter, and a test compares the two on the same rows.removePlacementalready does.deferredWriteIdis already stored on the row.heldAtmakes a held row'stimestampread the same on every query. Using the query's time would change it on each read.''or one for a write still held, and makes one whose row is gone answernull. A''entry it finds nothing for stays, since in a module'sstart()a write the last process held doesn't show yet, and deleting its entry there would lose it for good. Answeringnullfor''would leave messages written before the upgrade unfindable by that route, and a module that dedupes by it could store them again. With two rows under one external id, it takes the first, as context-manager'sfindByExternalId,landedRowand the Discord module'sstoredRowdo. A module that adds a second message under an id it already has a row for, while writes are held, then gets the older row back, where an unheld add would have made the newer one the entry. Taking the last row instead would answer the other writer's row for the copiesmain's Discord module stored twice, where the sync's held add came first, and the last row of a body stored in shards is no message's id. Keeping''rather than the handle in the map costs a store query on each lookup while the write is held. It keeps the module's state readable by an earlier release, where a handle would read as an id that names nothing.deferredWriteIdkeeps the key naming one write, so a handle, and boot's check, can't take a copy for it.test/discord-held-writes.test.tsruns the Discord module in a real framework, through a fake Discord client, so it checks the module against the framework's own held rows rather than a harness's model of them.ModuleMessagelets a module readheldwithout a cast. A stored row is assignable to it, so code that types rows asStoredMessagecompiles as before.metadata.deferredWriteId, as their stored rows will. Nothing reads it there, so they carry the metadata the write was given.What callers will notice
ctx.addMessageanswers a held write with a handle that startsheld:, where it answered''.ctx.queryMessagesanswers held rows after the stored ones, markedheld: true, with a provisionalsequenceandtimestampthat change when the write lands. A module that wants stored rows alone filters outheld.getMessage,editMessageandremoveMessagetake a handle as they take a stored id. Edits and removals of a held write, which threw "Message not found", now change or drop it.findMessageByExternalIdanswered''for a held write, it answers the first row under that external id: the write's handle while it's held and its stored id once it lands, or an older row the module added under the same external id. For a stored id whose row is gone, it answersnullwhere it answered that id.deferredWriteIdits metadata carries.ctx.queryMessages({})ingatherContext, which runs after the turn-start flush. Those would include messages held since the turn began, so fix(retrieval): take the recent window from stored messages only connectome-host#326, merged, keeps the window to stored rows.recovery/deferred-writes.json, orframework/deferred-writeswithout one) carriesheldAt. 0.22.2's and 0.22.3's boot reads keep an entry's other fields as they are, so a host that goes back to one of them reads the queue as before.Tests
All on Linux with Node 24.22.0, through the repository's own test flags (
--test-force-exitand theforce-exit-blocking-stdoutimport), atcdf6452f, which hasmain0a7fd161merged in. Red-first and the removals ran at9224463b, and the head's one later commit changes only an assertion inephemeral-settle, which neither runs.module-held-writespasses 18 of 18 anddiscord-held-writes4 of 4.main0a7fd161'ssrc/framework.ts,src/module-registry.tsandsrc/types/module.ts, the build is clean. The tests compile there with two additions: this change'sModuleMessagetype on the context's reads, and itssrc/held-writes.ts, whichmain's code doesn't call. 17 ofmodule-held-writes' 18 tests fail, and the 18th is the control below. All 4 ofdiscord-held-writes' tests fail.module-held-writes, with a clean typecheck. The pieces are the handle, the held rows in queries, their filter, the reverse, the limit, the shared-slot rule and its isolated-store exclusion, the provisional sequence, the held time, and the landed lookup. They also include edits and removals of held and landed writes, and the queue's persistence after each, quiesced or in a live turn. ThenheldAtthrough a re-deferral, and the boot read's keeping of a stored time, its type check and both sides of its range check. Then the external-id map's''for a held write and the lookup's seven steps, dropping a copieddeferredWriteId, and the guard for an id that isn't a string. Four of them also faildiscord-held-writes: held rows left out of queries, their filter, the edit of a held write and its removal.What the new tests protect:
test/module-held-writes.test.tspins the module context's contract for held writes:reverseandlimitover both kinds of row;heldAtthrough a re-deferral and an older queue file;''entries included, and a lookup inside a module'sstart()for a write the last process held.One test is a control. It compares the copied filter with context-manager's own query on the same rows, so a context-manager change to the rule shows here.
test/discord-held-writes.test.tspins the cases that started this:Not verified
addMessagea live turn takes too, and one test marks a turn alive to show that a write held there is answered by a handle. No test runs a live turn that stores what it held at a tool boundary.deferredWriteId, and an external-id lookup for a held write costs one by external id. None of this is measured.Out of scope
addMessageswith an external id never enter the context's external-id map, sofindMessageByExternalIddoesn't find them, as before. The Discord module already looks its rows up withqueryMessagesfor that reason.start():create()restores the deferred-writes queue after the modules start, as onmain. Restoring it before them would also need the host mode read and the quiesced state set before them: with the queue in memory and the host not yet known to be quiesced, acreate()that fails meanwhile releases what it built throughstop(), which would land writes a host quiesced for maintenance holds. That reorderscreate()around host mode, the event gate and the serving-boot flush.Written by Hilda-2681 (Claude Opus 5.5), on Ada-1017's design calls. Hilda-2580 found the defect on #516 and proposed the fix. She also read the change whole at
ce24dd42, then the commits after it through4b58daa4, and then throughcdf6452f, and approved each. Two internal reviews read the change, and then its fixes.changelog.d/module-held-writes.fixed.mdDescription checked and posted by Imogen-2490 (Claude Opus 5.5).
🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's Pi tooling, and posted from the shared
nissa-seruaccount.