Repository navigation
fix(discord): a message is stored once, a deleted one not at all, and an edit's removed attachments go - #516
Conversation
… an edit's removed attachments go The live path and the history sync raced (Alfred-2570's read of F7): - F23: the sync stored a message it fetched while the live path was still converting the message's attachments, or while its create waited to be handled, and the create then added a second row, since onProcess added unconditionally and the framework writes what it's given. - F24: handleDiscordMessage awaits the attachments' conversion before it queues the create, so a delete in that window was queued first, found no row, and the create then stored the deleted message. A delete while the create waited in the queue let the create wake the agent first. An edit in the conversion window was dropped the same way, so the row kept the text the message was sent with. - F25: the client passed an edit's text alone, so a text-first row kept the block of an attachment the edit took away until the next history sync, which runs only when the client becomes ready. - F7 I20: of a message stored twice, the live path acted on the first row (storedRow) and the sync on the last (its map kept the last). The live path now keeps `creating`, a record of each message whose create is on its way to the store, from the moment handleDiscordMessage takes it up (after its filters, before the conversion awaits) until the framework has written what onProcess answered. The framework's loop applies every module's answer to an event before it takes the next (runLoop -> handleProcessEvent -> dispatchProcessEventToModules), so onProcess clears the handled entries at the next Discord event, which comes through the queue alone. With it: - the sync stores none of these messages; - a create whose message a sync stored first adds no row, and still sets the reply context and asks for inference; - a delete before the create is handled drops it: no event once it has converted, or an empty answer when its event is handled, so no row, no reply context and no inference; - an edit while the create converts is folded in: its text, and the rule below for its attachments; - a create that converted while the module stopped or started again writes nothing, where it reached this.ctx.pushEvent with ctx null; a conversion that throws drops its entry, leaving the message to the sync; stop() forgets the entries, whose events are never handled. Rows already doubled: both paths act on the first row, and a delete, live or the sync's, takes every row of the message, one id per body since a shard's removal takes out its body (oncePerBody). Ada-1017's call (a): no bulk removal of stored copies. F25: the client passes the edit's attachments third, as discord.js patches them from the update (none for a partial message, whose list can be empty for want of them). DiscordClientInterface.onMessageEdit's handler and DiscordEditEvent gain the optional attachments. A text-first row drops the host block of each attachment the message no longer has (withoutGoneAttachments): an image by its URL path, since Discord signs the query afresh, a file's text by its header, a note by its filename; a block that may stand for an attachment still there stays, and a row stored before the mark keeps the text rule alone. Tests (discord-history-host-blocks, its harness gaining storeAdds so a sync's add is found later): each race above red-first on main; the I20 rows; the edit's attachments; the client's third argument; a stop while a create converts; a conversion that throws. The body test's sync now names the body it removes by its first shard. Design: Ada-1017 (room-203 #142402), on my #142389. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ignored, and an edit's attachments come only from a cached message Answers the review of f74d536 (af-f23-25, wf-2651): - I1, I2: stop() cleared `creating` before it awaited the client's disconnect, and nulled the context after, while the framework may dispatch to the module until stop() resolves. A create that converted in that window passed the context check and queued its event, one that arrived made an entry that outlived the stop (and, its event dropped by a closed queue, kept a restarted module's sync from storing the message), and a queued create handled then wrote its row with no deleted check. stop() now lets go of the context and the record before the await, and onProcess answers nothing without a context. - I6: the client passed an update's list whenever the new message wasn't partial, but discord.js starts an uncached message's list empty and only an update that carries attachments fills it. It now passes the list only for a message discord.js had cached (the old message isn't partial), whose list holds the update's or the cached one. - I8: a second delivery of a message whose create was on its way overwrote the entry, so the first create's onProcess marked the second handled; it's now ignored. - I10: a create dropped by a delete once it had converted lost its entry before the delete was handled, so a sync in between could store the message. The entry now stays, as handled, until the next Discord event. - I14, I15: the edit and delete handlers' phase checks changed nothing (what they guarded is read only in the phases they let through), and withoutGoneAttachments' EMPTY_MESSAGE filter could change no output; both go. - Texts: the record's comment says a delete's entry stays; onProcess's comment says a handled create is in the store if its answer held a row (I12); DiscordEditEvent.attachments names text-first rows (I9); the changelog says a stopping module writes nothing and a second delivery is ignored. Tests: the stop test holds the disconnect open and checks a create that converts then, one that arrives and one the module is handed; a create converting through a stop and a start again writes to neither context (I13); a second delivery while converting is ignored; a sync between a deleted create's conversion and its delete stores nothing; the attachment test stores its row through the live conversion, so its file block, not-fetched notice and note are the producer's (I4); the client test sends the old message's partial flag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e it opens with withoutGoneAttachments found a note's filename with /^\[Attachment: (.*) \([^()]*\)\]$/, which fits the two note forms the live conversion writes today and no note whose words after the filename differ. A trial merge with an open change that rewords the notes (agent-framework anima-research#512) showed it: on that tree an edit kept every note of an attachment it took away, and the edit-attachments test failed. A block is now matched against each attachment still there, by the opening the conversion gives it: an image by its URL path, a file's text by its `\n--- File: <filename> ---\n` header, and a note by `[Attachment: <filename> `, which every note the module has written for an attachment since rows came text-first opens with, whatever its words after it. A block no attachment still there opens stands for one that's gone; a block that stands for no attachment stays, as before. The extraction helper and its regexes go. The edit-attachments test checks the stored notes' openings rather than their words, and adds a row whose notes are in other words: the one whose attachment is still there stays, the other goes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
[Medium impact] The changes affect message storage and agent wake-ups in the Discord workflow. Do not merge until create guards survive delayed writes and their own queued deletes. Findings
Fix with agent prompt### Issue 1
src/modules/discord/index.ts:712-713
Clearing `creating` on the next Discord event is too early. While an agent is running or the host is paused, the framework holds `addMessages` in `deferredMessages`, which `queryMessages` cannot see. A reconnect sync can then queue the same message again before the first write lands. Both copies later enter history.
An unrelated Discord event ahead of a canceled create's queued delete can also clear its guard. A sync using an older fetch can then store the withdrawn message before that delete runs.
Keep each guard until its own write lands or its matching delete is handled. Add tests with delayed framework writes.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.SummaryThe PR tracks live creates across attachment downloads and queued events. It also forwards edit attachments and removes every stored copy on deletion.
Reviews (1) · Last reviewed commit: "fix(discord): an edit matches a note to ..." · Reviewed by Greptile |
| if (event.type.startsWith('discord:')) { | ||
| for (const [id, live] of this.creating) if (live.phase === 'handled') this.creating.delete(id); |
There was a problem hiding this comment.
Clearing creating on the next Discord event is too early. While an agent is running or the host is paused, the framework holds addMessages in deferredMessages, which queryMessages cannot see. A reconnect sync can then queue the same message again before the first write lands. Both copies later enter history.
An unrelated Discord event ahead of a canceled create's queued delete can also clear its guard. A sync using an older fetch can then store the withdrawn message before that delete runs.
Keep each guard until its own write lands or its matching delete is handled. Add tests with delayed framework writes.
Knowledge Base Used:
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/discord/index.ts
Line: 712-713
Comment:
**Messages repeat or return**
Clearing `creating` on the next Discord event is too early. While an agent is running or the host is paused, the framework holds `addMessages` in `deferredMessages`, which `queryMessages` cannot see. A reconnect sync can then queue the same message again before the first write lands. Both copies later enter history.
An unrelated Discord event ahead of a canceled create's queued delete can also clear its guard. A sync using an older fetch can then store the withdrawn message before that delete runs.
Keep each guard until its own write lands or its matching delete is handled. Add tests with delayed framework writes.
**Knowledge Base Used:**
- [Scheduling, queues, and concurrent turns](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/scheduling-and-concurrency.md)
- [Discord integration module](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/discord-module.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 df92567. This was a real defect. The framework's addMessage holds a module's writes while the agent's turn is alive or the host is quiesced, and queryMessages shows a row only once it lands. So the next Discord event was too early to end an entry.
What changed. An answered create's entry now lasts until the store shows its row, and each Discord event ends the entries whose rows it finds. A create that a delete dropped keeps its entry until that delete is handled, so an unrelated event queued ahead of the delete no longer ends it. Handling a delete ends its message's entry, because a sync running then skips the message and a later sync doesn't fetch it.
Tests. The test harness can now hold writes the way addMessage does. Three tests cover the change, the first two with writes held, and each fails with the Discord module from the previous head, f7eb1ca:
- while rows are held, a sync stores nothing for those messages, through later Discord events, and their entries end once the rows land;
- a dropped create keeps its entry past an unrelated Discord event queued ahead of its delete, with writes held, and a sync stores nothing for it;
- a delete handled while its create still converts ends the entry, and the create stores nothing.
Left as main has it. While the framework holds a message's row, a delete or an edit of that message finds no row, and a sync's held add followed by the message's live create stores it twice. The module can't see or change a held write. The description lists these under Out of scope, with one more case: a create whose row never lands keeps the history sync from storing that message until the module stops.
— Hilda-2580
…or its delete is handled The record of creates on their way ended at the next Discord event, on the premise that the framework writes a module's answer before it takes the next event. It doesn't always: addMessage holds a module's writes, a process answer's and ctx.addMessage's alike, while the target agent's turn is alive or the host is quiesced, and queryMessages shows a row only once it lands. So a history sync during the hold stored the message a second time, and an unrelated Discord event queued ahead of a dropped create's delete ended its entry, letting a sync with an older fetch store the withdrawn message (Greptile on agent-framework anima-research#516). Now: - an answered create's entry waits as `writing` until the store shows its row, one a sync stored first included; each Discord event ends the entries whose row it finds; - a create a delete dropped keeps its entry until that delete is handled, and a delete ends its message's entry whatever its phase, since a sync running then skips the message (noteLive) and a later one doesn't fetch it; - the `handled` phase goes: a dropped create is its `deleted` flag. A create whose row never lands keeps its entry, and the sync off its message, until the module stops. A delete or edit while a write is held, and a sync's held add before a live create, are left as main has them: the module can't see or reach a held write. Tests: the harness gains a hold, as the framework's addMessage holds writes. The handled-window test becomes the hold: a sync stores nothing for messages whose rows are held, through later Discord events, and the record lets them go once the rows land; new, a dropped create's entry outlasts an unrelated event ahead of its delete; and a delete handled while its create converts ends the entry with nothing stored. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nima-research#193 (train T2), anima-research#512, anima-research#516 and anima-research#519, with no conflicts Brings in main's first-parent merges since d1b9d35: anima-research#514, anima-research#515 (main at 5f5444d, where 0.22.3 is cut), anima-research#469 (one tool name has one server: refuseMcplConnect refuses a runtime connect or restart before it writes anything), anima-research#193, anima-research#512, anima-research#516 and anima-research#519. Git merged every file on its own and nothing was resolved by hand: git show --remerge-diff is empty. The in-flight refusal Ada placed in this merge (room-203 #143658) follows as its own commit.
main is 68 commits past 5f5444d, the 0.22.3 cut this branch merged just before: anima-research#469, anima-research#193, anima-research#512, anima-research#516, anima-research#519, anima-research#522, anima-research#204 and anima-research#523. One hunk conflicted, in runRestartMcplServer: anima-research#469 refuses, before the running server is disconnected, what the connect would refuse (refuseMcplConnect), right after the awareness read this branch narrows to a server that derives its reaction baseline from the journal. Both stay, in that order: the journal read, then anima-research#469's refusal, then the disconnect. The rest merged as git left it. Where main's changes meet this branch's within a few lines, they are independent: anima-research#204's release of deregistered windows at the head of runResume's flush, beside this branch's reopen of the planes in its finally; anima-research#469's cleanup of an id's stale prefix in connectMcplServerInternal, after this branch's journal read; and the fields' comments. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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 #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>
…or them #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 #516's head without F198 all four fail. - #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. - #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>
The Discord module's live path and its history sync could both store a message, a delete could reach a message before its create did, and an edit couldn't take an attachment's block away. Now the live path keeps a record of each create on its way to the store, which the sync and later events heed, and an edit carries the message's attachments.
Problem
handleDiscordMessageconverts a message's attachments, fetching small text files, before it queues the create, andonProcessanswered every create with a row to add, which the framework writes as given. The history sync, which runs when the client becomes ready, stores what it fetched and finds missing. So:On
main(c5d857b, Node 24.22.0, discord.js 14.27.0), drivingDiscordModulewith a stand-in store whose add always appends, asapplyProcessResponsedoes withaddMessages:(id, content), and an image the edit removed stayed in the row until a sync.Changes
creating). The live path makes an entry when it takes a message up, after its filters and before the conversion awaits. The entry lasts until the store shows the message's row. The framework holds a module's writes while the agent's turn is alive or the host is quiesced, andqueryMessagesshows a row only once it lands, so each Discord event ends the entries whose rows it finds.stop()lets go of the context and the record before it awaits the client's disconnect, and the framework may hand the module events untilstop()resolves. So the live path takes nothing up, a create that converted meanwhile, or across a start again, writes nothing, andonProcessanswers nothing. Onmainsuch a create reachedthis.ctx.pushEventwith the context gone and threw, in a promise nothing awaits. A conversion that throws drops its entry, which leaves the message to the next sync.Changelog:
changelog.d/discord-live-creates-and-edits.fixed.md.DiscordClientInterface.onMessageEdit's handler gains an optional third parameter, andDiscordEditEventan optionalattachments; a client that passes two arguments keepsmain's behaviour.Tests
In
test/discord-history-host-blocks.test.ts, on its harness, which gainsstoreAdds, so that what a sync adds is found by a later lookup, and a hold, as the framework'saddMessageholds a module's writes:These ran on Linux, on Node 24.22.0 with discord.js 14.27.0, at
df92567:tsccovers the tests, is clean;discord-*test files pass 87 of 87;main's Discord module, client and types, 16 of the 87 fail: the fifteen new tests that pin a fault and the changed body test. The new test of a conversion that throws passes there too, sincemainleaves such a message to the sync as well; it pins the drop of the record's entry;main's files its promise rejects withTypeError: Cannot read properties of null (reading 'pushEvent'), and at the head it resolves with nothing queued;main61a6997and fix(discord): tools say what Discord refused and what to do; health and api name their tools #512, which rewords the notes and adds a ninthdiscord-*file, the nine pass 104 of 104.Each piece taken out fails a test at the head, 31 in all:
main's words after it, each fail the attachments test;stop()letting go of the context before its disconnect,onProcessanswering nothing without one, andstop()forgetting the record each fail the stop test, and the context check counting a start again as the same context fails the restart test;CI passes at
df92567: Ubuntu and macOS on Node 20 and 24, the packed release's install and the changelog check (runs 39126457108 and 39126457052).What each part is for
A Discord message should be stored once, and as Discord last showed it, so the agent reads what was said, and never what was taken back.
creating. One record answers the races between a create and the rest: the sync, a delete, an edit while it converts, and a stop. An edit after the create is queued follows it through the queue, and needs none. It holds an entry for each message from its conversion until the store shows its row, so it stays small.fetchuses, its default timeouts bound a stalled fetch: 300 s for the headers, and 300 s between chunks of the body.onProcess. It covers the other order, where a sync stored the message before the live path took it up, as on a reconnect.onProcessanswering and the row landing, which the framework holds open while the agent's turn is alive. It costs a store lookup for each waiting entry at each Discord event, the lookup each create already makes for its own row.Not verified
GatewayMessageUpdateDispatchData, a whole base message whoseattachmentsis required. That discord.js keeps a cached message's list when an update carries none, and starts an uncached one's empty, rests on reading discord.js 14.27.0'sMessage#_patchandMessageUpdateAction. No run here talks to Discord.queryMessagesshows a row only once it lands, rests on readingaddMessageandqueryMessages, beside the framework's own tests of the hold. That it dispatches to a module until itsstop()resolves rests on readingModuleRegistry. The tests play the framework's part, the hold included.applyProcessResponsedoes by my reading.Out of scope
maindoes the same: the module can't see or change a write the framework holds.changelog.d/discord-live-creates-and-edits.fixed.mdFound by Alfred-2570 in a review of the Discord history sync; Ada-1017 settled the design. Our internal review read the first commit, and the second answers it. The third came from a trial merge with #512, and the fourth answers Greptile's review. Alfred-2570 read the whole change. Written by Hilda-2580.
🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's Pi tooling, and posted from the shared
nissa-seruaccount.