From 2a46c1d7a62e1a37454d7ac84307d9d36588ac77 Mon Sep 17 00:00:00 2001 From: Omkar Chebale Date: Sat, 3 Oct 2026 00:45:15 +0530 Subject: [PATCH 1/3] Keep a Bot's saved group reply when handing it on fails runBot saved the reply as completed, then ran activity, consent cards and relay inside the same try. A throw from any of them reached the catch, which wrote the error message over the saved reply as failed. Once the reply is saved, log the fault instead. --- CHANGELOG.md | 8 +++++ server/src/channels/group.ts | 15 +++++++++ .../group-conversations.integration.test.ts | 32 ++++++++++++++++++- 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9febb9a50..e0d759bd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased +### A Bot's saved reply in a group is no longer replaced by a later error + +In a group conversation, a Bot's reply was saved, then handed on: to Activity, to any consent +cards, and to the Bots it named. A fault in that hand-on, such as the audit trail being +unreachable, wrote the error's text over the saved reply and marked it failed, and a retry did not +bring the reply back. The reply now stays as saved, and the fault is logged as +`group-turn-after-reply-error`. + ### A playground component's Published switch publishes its source too The Published switch on an admin component page called the generic publication endpoint for every diff --git a/server/src/channels/group.ts b/server/src/channels/group.ts index 79a7a6a5e..61d55c478 100644 --- a/server/src/channels/group.ts +++ b/server/src/channels/group.ts @@ -780,6 +780,7 @@ export function createGroupConversations(deps: { // Partial text, written at most this often, so the transcript shows the reply as it arrives. let latest: string | null = null; let writing: Promise | null = null; + let saved = false; let lastWrite = 0; const every = deps.progressEveryMs ?? 400; const flush = () => { @@ -850,10 +851,24 @@ export function createGroupConversations(deps: { return; } await deps.store.finish(id, result.replyText, "completed"); + saved = true; await deps.activity?.(turn, agentId, result.replyText, `group:${id}`); await postConsents(turn, agentId, threadId, id, consents); await relay(turn, bot, id, result.replyText, bots, runId); } catch (error) { + // The reply is already saved: a fault handing it on is not this Bot's answer failing, and + // writing it over the row would replace a good reply with an error that no retry repairs. + if (saved) { + console.error( + JSON.stringify({ + type: "group-turn-after-reply-error", + error: error instanceof Error ? error.message : String(error), + context: { channelId: turn.channelId, agentId, rowId: id }, + timestamp: new Date().toISOString(), + }), + ); + return; + } await writing; await postConsents(turn, agentId, threadId, id, consents); if (error instanceof HeadlessToolSuspension) diff --git a/server/tests/group-conversations.integration.test.ts b/server/tests/group-conversations.integration.test.ts index 57359e16e..906a6c13d 100644 --- a/server/tests/group-conversations.integration.test.ts +++ b/server/tests/group-conversations.integration.test.ts @@ -91,6 +91,7 @@ function service(options: { >; maxDepth?: number; granted?: boolean; + auditFails?: boolean; privateShare?: Parameters[0]["privateShare"]; listenForConsent?: Parameters< typeof createGroupConversations @@ -122,7 +123,12 @@ function service(options: { }, caps: { maxDepth: options.maxDepth ?? 1, maxPerRun: 2 }, mayAddress: async () => options.granted ?? true, - auditStore: { insert: async (event) => void audit.push(event) }, + auditStore: { + insert: async (event) => { + if (options.auditFails) throw new Error("audit store unavailable"); + audit.push(event); + }, + }, createChannel: async (ownerUserId, agentIds) => { const channel = await channelStore.create( { id: ownerUserId, role: "user" }, @@ -316,6 +322,30 @@ describe("group conversations in PostgreSQL", () => { ).filter((row) => row.key.startsWith(prefix) && row.key.includes(">")), ).toEqual([]); }); + test("a fault handing a saved reply on does not write over the reply", async () => { + const owner = await person(); + const ada = await bot(owner, "Ada"); + const grace = await bot(owner, "Grace"); + const channel = await channelStore.create(owner, [ada, grace]); + createdChannels.push(channel.id); + const { conversations } = service({ + auditFails: true, + replies: { [ada]: () => "@Grace over to you", [grace]: () => "ok" }, + }); + await conversations.send(owner.id, channel.id, { + id: `${prefix}-relay-fault`, + text: "go", + agentId: ada, + }); + await drain(conversations); + const reply = ( + await conversations.list(owner.id, channel.id) + ).messages.find((row) => row.agentId === ada); + expect(reply).toMatchObject({ + status: "completed", + text: "@Grace over to you", + }); + }); test("Bots answer in the order chosen, or in the order a message names them", async () => { const owner = await person(); const ada = await bot(owner, "Ada"); From 703be610f36c0877f6db89af1baa645db79e831f Mon Sep 17 00:00:00 2001 From: David McKay Date: Fri, 2 Oct 2026 13:44:07 -0700 Subject: [PATCH 2/3] Keep the upgrade note at the top of Unreleased --- CHANGELOG.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed0cba3f5..1bb9acd45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,14 +8,6 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased -### A Bot's saved reply in a group is no longer replaced by a later error - -In a group conversation, a Bot's reply was saved, then handed on: to Activity, to any consent -cards, and to the Bots it named. A fault in that hand-on, such as the audit trail being -unreachable, wrote the error's text over the saved reply and marked it failed, and a retry did not -bring the reply back. The reply now stays as saved, and the fault is logged as -`group-turn-after-reply-error`. - **Before upgrading.** Four things change for an existing deployment: - Automatic Learning is on unless an administrator saved it off. It does nothing until a Learning container is assigned; see below. @@ -28,6 +20,14 @@ bring the reply back. The reply now stays as saved, and the fault is logged as - An existing Windows clone checks text files out with LF only after `git rm -r --cached . && git reset --hard` on a clean tree. +### A Bot's saved reply in a group is no longer replaced by a later error + +In a group conversation, a Bot's reply was saved, then handed on: to Activity, to any consent +cards, and to the Bots it named. A fault in that hand-on, such as the audit trail being +unreachable, wrote the error's text over the saved reply and marked it failed, and a retry did not +bring the reply back. The reply now stays as saved, and the fault is logged as +`group-turn-after-reply-error`. + ### The Helm chart configures Slack, Teams, text messages, push, SCIM, inbound email and OpenTelemetry These settings had no chart values and could only be passed through `config.extraEnv`. They now have From 0c2634661ab0327da94796a1e0e842a8f5054fa6 Mon Sep 17 00:00:00 2001 From: David McKay Date: Fri, 2 Oct 2026 13:46:32 -0700 Subject: [PATCH 3/3] Still post consents and hand a saved group reply on when a later step fails The early return kept a saved reply from being overwritten, but it also skipped the consent cards and the relay to the Bots the reply named. The turn now records how far past the save it got and, on a fault, finishes the steps that never started, without repeating a relay that began. --- server/src/channels/group.ts | 34 +++++++++++++++++ .../group-conversations.integration.test.ts | 38 +++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/server/src/channels/group.ts b/server/src/channels/group.ts index 61d55c478..68430e3a5 100644 --- a/server/src/channels/group.ts +++ b/server/src/channels/group.ts @@ -780,7 +780,12 @@ export function createGroupConversations(deps: { // Partial text, written at most this often, so the transcript shows the reply as it arrives. let latest: string | null = null; let writing: Promise | null = null; + // How far past the save this turn got, so a fault after it finishes what never started without + // repeating what did: consents are collected once, and a relay that began may have sent hops. let saved = false; + let savedReply = ""; + let consentsPosted = false; + let relayStarted = false; let lastWrite = 0; const every = deps.progressEveryMs ?? 400; const flush = () => { @@ -852,8 +857,11 @@ export function createGroupConversations(deps: { } await deps.store.finish(id, result.replyText, "completed"); saved = true; + savedReply = result.replyText; await deps.activity?.(turn, agentId, result.replyText, `group:${id}`); + consentsPosted = true; await postConsents(turn, agentId, threadId, id, consents); + relayStarted = true; await relay(turn, bot, id, result.replyText, bots, runId); } catch (error) { // The reply is already saved: a fault handing it on is not this Bot's answer failing, and @@ -867,6 +875,32 @@ export function createGroupConversations(deps: { timestamp: new Date().toISOString(), }), ); + const unfinished = async (step: string, run: () => Promise) => { + try { + await run(); + } catch (stepError) { + console.error( + JSON.stringify({ + type: "group-turn-after-reply-error", + step, + error: + stepError instanceof Error + ? stepError.message + : String(stepError), + context: { channelId: turn.channelId, agentId, rowId: id }, + timestamp: new Date().toISOString(), + }), + ); + } + }; + if (!consentsPosted) + await unfinished("consents", () => + postConsents(turn, agentId, threadId, id, consents), + ); + if (!relayStarted) + await unfinished("relay", () => + relay(turn, bot, id, savedReply, bots, runId), + ); return; } await writing; diff --git a/server/tests/group-conversations.integration.test.ts b/server/tests/group-conversations.integration.test.ts index 906a6c13d..7eba9fb16 100644 --- a/server/tests/group-conversations.integration.test.ts +++ b/server/tests/group-conversations.integration.test.ts @@ -96,6 +96,7 @@ function service(options: { listenForConsent?: Parameters< typeof createGroupConversations >[0]["listenForConsent"]; + activity?: Parameters[0]["activity"]; }) { const calls: Parameters[0][] = []; const audit: AuditEventInput[] = []; @@ -142,6 +143,7 @@ function service(options: { ...(options.listenForConsent ? { listenForConsent: options.listenForConsent } : {}), + ...(options.activity ? { activity: options.activity } : {}), }); return { conversations, calls, audit }; } @@ -322,6 +324,42 @@ describe("group conversations in PostgreSQL", () => { ).filter((row) => row.key.startsWith(prefix) && row.key.includes(">")), ).toEqual([]); }); + test("a fault after a reply is saved still posts its consent cards and hands it on", async () => { + const owner = await person(); + const ada = await bot(owner, "Ada"); + const grace = await bot(owner, "Grace"); + const channel = await channelStore.create(owner, [ada, grace]); + createdChannels.push(channel.id); + const { conversations, calls } = service({ + replies: { [ada]: () => "@Grace over to you", [grace]: () => "ok" }, + // Only Ada's saved reply fails to reach the activity feed; the person's message still does. + activity: async (_turn, agentId) => { + if (agentId === ada) throw new Error("activity feed unavailable"); + }, + listenForConsent: (_ownerUserId, agentId) => () => + agentId === ada + ? [{ botId: ada, serverId: "gmail", message: "Ada needs your mail." }] + : [], + }); + await conversations.send(owner.id, channel.id, { + id: `${prefix}-activity-fault`, + text: "go", + agentId: ada, + }); + await drain(conversations); + const { messages } = await conversations.list(owner.id, channel.id); + expect( + messages.find((row) => row.agentId === ada && !row.consent), + ).toMatchObject({ + status: "completed", + text: "@Grace over to you", + }); + expect(messages.some((row) => row.consent?.serverId === "gmail")).toBe( + true, + ); + expect(calls.some((call) => call.agentId === grace)).toBe(true); + }); + test("a fault handing a saved reply on does not write over the reply", async () => { const owner = await person(); const ada = await bot(owner, "Ada");