Skip to content

fix(discord): a message is stored once, a deleted one not at all, and an edit's removed attachments go - #516

Merged
nissa-seru merged 4 commits into
anima-research:mainfrom
nissa-seru:fix/discord-live-path-and-sync
Oct 18, 2026
Merged

nissa-seru merged 4 commits into
anima-research:mainfrom
nissa-seru:fix/discord-live-path-and-sync

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

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

handleDiscordMessage converts a message's attachments, fetching small text files, before it queues the create, and onProcess answered 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:

  • A message could be stored twice. A sync that fetched it while its create converted, or waited in the queue, stored it, and the create then added a second row.
  • A deleted message could stay stored. A delete during the conversion was queued first, found no row, and the create then stored the message. A delete while the create waited in the queue was handled after it, so the agent could be woken by, and answer, a message its author had taken back.
  • An edit during the conversion was lost. It found no row and was dropped, and the row kept the text the message was sent with until the next sync.
  • An edit that removed an attachment kept its block. The client passed the edit's text alone, so the image, file text or note stayed until the next sync.
  • Rows already doubled went two ways. A live edit acted on a message's first row, and the sync on its last.

On main (c5d857b, Node 24.22.0, discord.js 14.27.0), driving DiscordModule with a stand-in store whose add always appends, as applyProcessResponse does with addMessages:

  • a message the sync stored, whose create was handled after, has two rows, and so does one the sync stored while its create was fetching a file;
  • of two rows for one message, a live edit changed the first and a sync the last;
  • a delete during the create's file fetch was queued first, and the message was then stored;
  • an edit in that window left the row with the first wording;
  • the client handed the module (id, content), and an image the edit removed stayed in the row until a sync.

Changes

  • A record of each create on its way (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, and queryMessages shows a row only once it lands, so each Discord event ends the entries whose rows it finds.
    • The sync stores none of these messages. The live path does. A second delivery of a message whose create is on its way is ignored.
    • A create whose message a sync stored first adds no row. It still sets the reply context and asks for inference, as the message arrived live.
    • A delete before the create is handled drops it. Once it has converted, no event is queued, and its entry stays until that delete is handled, so no sync stores the message before then. When its event is handled, the answer is empty. Either way there's no row, no reply context and no inference. The delete's own event still goes, for a row a sync stored first.
    • A delete ends its message's entry. A sync running then skips the message, and a later one doesn't fetch it.
    • An edit while the create converts is folded into it: its text, and its attachments by the rule below. One that comes after follows the create through the queue. The edit's own event still goes, for a row a sync stored first.
    • A stopping module writes nothing. stop() lets go of the context and the record before it awaits the client's disconnect, and the framework may hand the module events until stop() resolves. So the live path takes nothing up, a create that converted meanwhile, or across a start again, writes nothing, and onProcess answers nothing. On main such a create reached this.ctx.pushEvent with 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.
  • Rows already doubled. Both paths act on a message's first row. A delete, live or found by the sync, removes every row of the message, one id per body, since removing a shard takes out its body. The copies are otherwise left as they are.
  • An edit's attachments. The client passes them as a third argument, for a message discord.js had cached: it takes the update's list when the update carries one, and keeps the cached list when it doesn't. A message it hadn't cached starts with an empty list, so the client passes none for it. A row stored with its text first drops the block of each attachment its message no longer has: an image by its URL without the query, since Discord signs the query afresh, a file's text by its header, a note by the filename it opens with, whatever its words after it. A block that may stand for an attachment still there stays, such as one of two files of one name. A row stored before its text came first keeps the text rule alone.

Changelog: changelog.d/discord-live-creates-and-edits.fixed.md. DiscordClientInterface.onMessageEdit's handler gains an optional third parameter, and DiscordEditEvent an optional attachments; a client that passes two arguments keeps main's behaviour.

Tests

In test/discord-history-host-blocks.test.ts, on its harness, which gains storeAdds, so that what a sync adds is found by a later lookup, and a hold, as the framework's addMessage holds a module's writes:

  • New:
    • a create whose message a sync stored first adds no row, and still asks for inference and sets the reply context;
    • a sync stores nothing for a message whose create is converting its file, and the create stores it once;
    • while the framework holds the answers' rows, a sync stores nothing for those messages, through later Discord events, and their entries end once the rows land;
    • of a message stored twice, a live edit and a sync both change the first row, and a live delete or a sync's delete takes every row;
    • a delete while the create converts a file drops it: no event for the create, no row;
    • a delete while the create waits in the queue drops it when handled: no row, no reply context, no inference;
    • an edit while the create converts is folded in: its text, without the block of the attachment it took away;
    • an edit drops the blocks of attachments taken away, from a row the live conversion stored: an image whose URL comes signed afresh stays, a file, a notice that a file wasn't fetched and a note go, one of two files of one name stays, a note in other words is matched by the filename it opens with, and a row stored before the mark, or an edit without attachments, keeps its blocks;
    • the client passes an edit's attachments for a message discord.js had cached, and none for one it hadn't;
    • a stopping module writes nothing while its disconnect is held open: a create that converts then, one that arrives, and one it's handed; a sync after a restart stores a message whose create was queued at the stop;
    • a create converting through a stop and a start again writes to neither context;
    • a second delivery of a message whose create converts is ignored, so a delete meanwhile still drops it;
    • a sync between a deleted create's conversion and its delete being handled stores nothing for it;
    • a create a delete dropped keeps a sync from storing its message until that delete is handled, though another Discord event comes first and the framework holds writes;
    • a delete handled while its create still converts ends its entry, and the create stores nothing;
    • a create whose conversion throws leaves its message to the sync.
  • Changed: the test of a body stored in shards now sees the sync remove a deleted body by its first shard, where it named the last; it still takes out the whole body.

These ran on Linux, on Node 24.22.0 with discord.js 14.27.0, at df92567:

  • the build, whose tsc covers the tests, is clean;
  • the eight discord-* test files pass 87 of 87;
  • with 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, since main leaves such a message to the sync as well; it pins the drop of the record's entry;
  • a probe of a create still converting its file when the module stops: on main's files its promise rejects with TypeError: Cannot read properties of null (reading 'pushEvent'), and at the head it resolves with nothing queued;
  • merged with main 61a6997 and 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 ninth discord-* file, the nine pass 104 of 104.

Each piece taken out fails a test at the head, 31 in all:

  • the create's check for a stored row fails the sync-first test;
  • the sync's skip of a create on its way fails four tests, the converting, the hold and the dropped-create tests among them;
  • ending entries at a Discord event fails the hold and the dropped-create tests, an answered create's wait for its row fails both, and ending an entry before the store shows its row fails the hold test;
  • the drop of a deleted create after its conversion fails five tests, the converting-delete test among them; its entry kept until its delete is handled fails the sync-between and the dropped-create tests; the drop when its event is handled fails the queued-delete test; the delete's mark fails six tests, both delete tests among them; and a delete ending its message's entry fails the dropped-create test and the test of a delete handled while its create converts;
  • ignoring a second delivery fails the second-delivery test;
  • the edit's record while the create converts, and the folded edit's text and its attachments, each fail the fold test;
  • the edit's attachment rule, the URL compared without its query, the match of a file's header and of a note, and a note's match by its opening alone, rather than by main's words after it, each fail the attachments test;
  • the sync acting on the last row fails the doubled-rows and body tests, and a live delete or the sync's delete of the first row alone fails the doubled-rows test;
  • removing each shard of a body, instead of the body once, fails the body test;
  • stop() letting go of the context before its disconnect, onProcess answering nothing without one, and stop() forgetting the record each fail the stop test, and the context check counting a start again as the same context fails the restart test;
  • the client passing an uncached message's list fails the client test;
  • a throwing conversion that keeps its entry fails the throw 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.
    • Set aside: ordering each message's events through a chain of promises. It would queue a delete after its create, so the create would still wake the agent first.
    • The price: a conversion that stalls keeps the sync from storing that message until it ends. By our reading of undici 7, which Node's fetch uses, its default timeouts bound a stalled fetch: 300 s for the headers, and 300 s between chunks of the body.
  • No second add in onProcess. It covers the other order, where a sync stored the message before the live path took it up, as on a reconnect.
  • Ending an entry when the store shows its row. It closes the window between onProcess answering 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.
  • Ending an entry at its delete. After the delete is handled, a sync running then skips the message, and a later one doesn't fetch it, so the record needn't hold it longer.
  • Every row on a delete, the first for an edit. A message already stored twice then can't outlive its delete, and its paths agree. Removing the copies in bulk would rewrite residents' history to tidy a duplicate the agent can already read past.
  • The edit's attachments. Without them, a removed attachment's block stays until the client next becomes ready. Matching each block to its attachment fetches nothing and needs no ordering. A note is matched by the filename it opens with, so its words after it can change without changing what it matches.
    • Set aside: converting the message again on such an edit. It would fetch files, and an edit would then need the ordering a create gets.
  • The stop and throw guards. Each is a way out of the create that would otherwise leave an entry, a rejection or a write behind.

Not verified

  • Discord's own payloads. That an update carries the message's attachments rests on reading discord-api-types 0.38.56's GatewayMessageUpdateDispatchData, a whole base message whose attachments is 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's Message#_patch and MessageUpdateAction. No run here talks to Discord.
  • The framework's hold. That it holds a module's writes while the agent's turn is alive or the host is quiesced, and that queryMessages shows a row only once it lands, rests on reading addMessage and queryMessages, beside the framework's own tests of the hold. That it dispatches to a module until its stop() resolves rests on reading ModuleRegistry. The tests play the framework's part, the hold included.
  • A real store. The tests use the harness's stand-in, which writes as applyProcessResponse does by my reading.

Out of scope

  • An edit that adds an attachment, which Discord's API allows, is still left to the next sync.
  • A folded edit's mentions. A create keeps the inference decision its message was sent with, though an edit while it converted may have changed whom it mentions.
  • Rows already doubled keep their copies until the message is deleted.
  • A delete or an edit while the framework holds the message's row finds no row, so the message is stored as it was created. A sync's add is held the same way, so a live create that follows it adds a second row. main does the same: the module can't see or change a write the framework holds.
  • A create whose row never lands, as, by our reading, when another module's answer to the same event fails to apply, keeps the history sync from storing that message until the module stops.

  • Changelog fragment added: changelog.d/discord-live-creates-and-edits.fixed.md

Found 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-seru account.

Hilda-2580 and others added 3 commits October 18, 2026 06:59
… 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>
@greptile-apps

greptile-apps Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 4/5 Tier: apex

[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

  1. P1 Messages repeat or return ▶
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.

Summary

The PR tracks live creates across attachment downloads and queued events. It also forwards edit attachments and removes every stored copy on deletion.

  • Discord creates reach storage once, unless a delete gets there first.
  • Discord edits carry their latest text and attachment list into stored messages.
  • Edits use the first stored copy, while deletes remove every copy.

Reviews (1) · Last reviewed commit: "fix(discord): an edit matches a note to ..." · Reviewed by Greptile

Comment thread src/modules/discord/index.ts Outdated
Comment on lines +712 to +713
if (event.type.startsWith('discord:')) {
for (const [id, live] of this.creating) if (live.phase === 'handled') this.creating.delete(id);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 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:

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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>
@nissa-seru
nissa-seru merged commit ba63314 into anima-research:main Oct 18, 2026
6 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
…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.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
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>
nissa-seru pushed a commit that referenced this pull request Oct 18, 2026
…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>
nissa-seru pushed a commit that referenced this pull request Oct 18, 2026
Brings #516 (F23-F25, the Discord module's record of creates on their way), which F198's Discord part builds on, with #469, #193, #512 and #519.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit that referenced this pull request Oct 18, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant