fix(chat): close unmatched X reply receipts after recovery window

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-27 21:32:30 -05:00
1 parent 1eac963995
commit af329e2329
3 files changed
+23 -2

No files matched your search

+2 -1
View File
@@ -80,7 +80,8 @@ runtime using persisted activation roots. Its automatic `postMessage` path is
disabled. Reply targets never depend on the adapter's in-memory latest post.
Follow-ups that arrive before their bot-parent link is committed remain in the
intake ledger and resume when that exact link becomes available, including
after a restart. Unrelated replies cannot start tasks.
after a restart. Unrelated replies cannot start tasks. Unmatched replies close
after 24 hours with an ignored disposition; their audit receipt is retained.
The publication ledger saves exact text and target before any provider request.
The same key and payload return the original intent. Reusing a key with a
@@ -72375,6 +72375,11 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
expect(f.wakeup).toHaveBeenCalledTimes(3);
await f.drain();
expect(f.wakeup).toHaveBeenCalledTimes(3);
await db.update(chatActions).set({ createdAt: new Date(Date.now() - 25 * 60 * 60 * 1000) }).where(and(eq(chatActions.endpointId, f.endpoint.id), eq(chatActions.providerActionId, "x-post:603")));
await f.drain();
const [expired] = await db.select().from(chatActions).where(and(eq(chatActions.endpointId, f.endpoint.id), eq(chatActions.providerActionId, "x-post:603")));
expect(expired).toMatchObject({ status: "processed", result: { disposition: "ignored", reason: "X reply parent was not recorded within 24 hours" } });
expect(f.wakeup).toHaveBeenCalledTimes(3);
}, 60_000);
it("X concurrent reply intents and workers preserve each branch's exact target after restart", async () => {
+16 -1
View File
@@ -270,6 +270,7 @@ export function xChannelService(db: Db, hooks: XHooks, fetchImpl = fetch) {
: null;
}
async function processIngress(limit = 25) {
const unmatchedBefore = new Date(Date.now() - 24 * 60 * 60 * 1000);
// Unmatched direct replies stay durable without occupying the ready queue.
// Once the exact parent link exists, any process can continue the task.
const awaitingParentReady = and(
@@ -280,6 +281,10 @@ export function xChannelService(db: Db, hooks: XHooks, fetchImpl = fetch) {
sql`${chatActions.payload}->'event'->>'event_type' = 'post.mention.create'`,
sql`not exists (select 1 from chat_publications publication where publication.endpoint_id = ${chatActions.endpointId} and publication.state = 'streaming' and publication.payload->'xReply' is not null)`,
),
and(
sql`${chatActions.payload}->'event'->>'event_type' = 'post.reply.create'`,
lte(chatActions.createdAt, unmatchedBefore),
),
),
);
const rows = await db
@@ -339,10 +344,20 @@ export function xChannelService(db: Db, hooks: XHooks, fetchImpl = fetch) {
? event.payload.paperclipThreadId
: await resolveThread(endpoint, event);
if (!threadId && xParent(event.payload)) {
const expired =
event.event_type === "post.reply.create" &&
claimed.createdAt <= unmatchedBefore;
await db
.update(chatActions)
.set({
status: "awaiting_parent",
status: expired ? "processed" : "awaiting_parent",
result: expired
? {
claim,
disposition: "ignored",
reason: "X reply parent was not recorded within 24 hours",
}
: { claim },
payload: {
...claimed.payload,
parentPostId: xParent(event.payload),