From 62a1f1d5204eb41a80376c013f4f661e0db32990 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:25:57 -0800 Subject: [PATCH 1/2] fix(native): every failed ask in a shared pod gets its own 'couldn't finish' line The fixed line a hosted turn posts when it fails with nothing posted is identical every time, so in a shared pod the 30-minute duplicate guard skipped every repeat. On 2026-11-01 five asks in one chat pod failed within five minutes and only the first got the line; four got silence. 1:1 pods were already exempt. The native runtime now marks the line failureNotice, a server-only option the HTTP routes never forward, and the duplicate guard skips it. A caller's metadata cannot claim it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AKgpQ2pr6xRMhN5mcfoHF --- .../agentMessageService.summary.test.js | 21 ++++++++++++++++++- .../nativeRuntimeService.guardStop.test.js | 6 ++++++ backend/services/agentMessageService.ts | 13 +++++++++++- backend/services/nativeRuntimeService.ts | 1 + 4 files changed, 39 insertions(+), 2 deletions(-) diff --git a/backend/__tests__/unit/services/agentMessageService.summary.test.js b/backend/__tests__/unit/services/agentMessageService.summary.test.js index 8527051e61..23a84f7794 100644 --- a/backend/__tests__/unit/services/agentMessageService.summary.test.js +++ b/backend/__tests__/unit/services/agentMessageService.summary.test.js @@ -169,7 +169,7 @@ describe('AgentMessageService summary persistence', () => { createdAt: new Date(Date.now() - 3 * 60 * 1000).toISOString(), userId: { _id: userId }, }]; - const repost = (podType) => { + const repost = (podType, extra = {}) => { Pod.findById.mockReturnValue(podLookup(podType)); jest.spyOn(AgentMessageService, 'getRecentMessages').mockResolvedValue(identicalRecentPostBy('agent-user-1')); return AgentMessageService.postMessage({ @@ -178,6 +178,7 @@ describe('AgentMessageService summary persistence', () => { podId: 'pod-dm-1', content: 'I can read and search this repo, answer questions, and help you set things up.', metadata: { sourceEventType: 'chat.mention' }, + ...extra, }); }; afterEach(() => { AgentMessageService.getRecentMessages.mockRestore(); }); @@ -207,6 +208,24 @@ describe('AgentMessageService summary persistence', () => { expect(result.reason).toBe('duplicate_recent'); expect(Message).not.toHaveBeenCalled(); }); + + // 2026-11-01: five asks in a shared pod failed in five minutes, and only + // the first drew the fixed "couldn't finish" line. The other four repeats + // were skipped as duplicates, so four asks got silence. Each line answers + // its own ask. + it('posts a failed-turn notice in a shared pod even when the same line went out minutes ago', async () => { + const result = await repost('chat', { failureNotice: true }); + expect(result.skipped).toBeUndefined(); + expect(Message).toHaveBeenCalled(); + }); + + it('does not let a caller claim the exemption through metadata it wrote', async () => { + const result = await repost('chat', { + metadata: { sourceEventType: 'chat.mention', runFailed: true, failureNotice: true }, + }); + expect(result.reason).toBe('duplicate_recent'); + expect(Message).not.toHaveBeenCalled(); + }); }); it('routes likely error content to agent-admin DM and posts system notice', async () => { diff --git a/backend/__tests__/unit/services/nativeRuntimeService.guardStop.test.js b/backend/__tests__/unit/services/nativeRuntimeService.guardStop.test.js index 42592661a8..98b686e1eb 100644 --- a/backend/__tests__/unit/services/nativeRuntimeService.guardStop.test.js +++ b/backend/__tests__/unit/services/nativeRuntimeService.guardStop.test.js @@ -260,6 +260,9 @@ describe('a hosted turn the guard stops, or that fails', () => { expect(chatBodies).toHaveLength(4); expect(mockGetPage.mock.calls).toEqual([[STOPPED_URL]]); expect(posted()).toEqual([HOSTED_RUN_FAILED_LINE]); + // Marked as a failed-turn notice, so a shared pod's duplicate guard + // cannot swallow the line for a second failed ask. + expect(AgentMessageService.postMessage.mock.calls[0][0].failureNotice).toBe(true); expect(savedRun.metered).toBe(false); }); @@ -548,6 +551,9 @@ describe('a hosted turn the guard stops, or that fails', () => { expect(result).toEqual(expect.objectContaining({ status: 'failed', errorKind: 'tool_error' })); expect(savedRun.metered).toBe(false); expect(posted()).toEqual(['Draft answer', HOSTED_RUN_FAILED_LINE]); + // Only the fixed line carries the exemption; the agent's own reply does not. + expect(AgentMessageService.postMessage.mock.calls.map(([args]) => args.failureNotice === true)) + .toEqual([false, true]); }); test('neither the fixed line nor the set-aside notice repeats the guard or the page', async () => { diff --git a/backend/services/agentMessageService.ts b/backend/services/agentMessageService.ts index ffb5f9b863..d48993ee4e 100644 --- a/backend/services/agentMessageService.ts +++ b/backend/services/agentMessageService.ts @@ -195,6 +195,10 @@ interface PostMessageOptions { // nudges speak beside an agent seat as Commonly. Ordinary agent posts stay // unmarked even if their metadata happens to contain a similar label. authorKind?: 'kernel' | null; + // Server-set, never model-set and never read from metadata: the fixed line a + // hosted turn posts when it failed with nothing posted. Each one answers its + // own ask, so the shared-pod duplicate guard does not apply to it. + failureNotice?: boolean; } interface PostTargetOptions { @@ -1021,6 +1025,7 @@ class AgentMessageService { noOutboundRelay = false, answeringMessageId, authorKind = null, + failureNotice = false, } = options; // Typing indicator cleanup — once postMessage is entered, the runtime has @@ -1494,8 +1499,14 @@ class AgentMessageService { // test file now pins the measured behaviour instead. Whether agent-admin // SHOULD be exempt is an open board decision, not settled here. // One predicate, read once, so the two guards cannot drift apart. + // + // A failed-turn notice is exempt in every pod for the same reason: it is + // fixed text, so every repeat matches, and each repeat answers a new ask. + // On 2026-11-01 five asks failed in five minutes in a shared pod and four + // got silence. Only the native runtime sets `failureNotice`; the HTTP + // routes never forward it. const isOneToOne = AgentMessageService.isOneToOnePod(dedupePod?.type); - const duplicate = isOneToOne + const duplicate = (isOneToOne || failureNotice === true) ? null : await AgentMessageService.findRecentDuplicate({ podId, diff --git a/backend/services/nativeRuntimeService.ts b/backend/services/nativeRuntimeService.ts index b3cf5aa0eb..78d6bfd471 100644 --- a/backend/services/nativeRuntimeService.ts +++ b/backend/services/nativeRuntimeService.ts @@ -2720,6 +2720,7 @@ export async function runAgent( displayName, installationConfig: cfg, metadata: { source: 'native-runtime', runFailed: true }, + failureNotice: true, noOutboundRelay: Boolean(dispatchCtx.webTainted || dispatchCtx.brokerCallSeen), answeringMessageId: dispatchCtx.answeringMessageId, threadRootId: dispatchCtx.answeringThreadRootId ?? null, From 45c194fae1a27bc9bc681fca655ab323f4fd920f Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:27:24 -0800 Subject: [PATCH 2/2] test(native): the model-failure line carries failureNotice too Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AKgpQ2pr6xRMhN5mcfoHF --- .../unit/services/nativeRuntimeService.guardStop.test.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/__tests__/unit/services/nativeRuntimeService.guardStop.test.js b/backend/__tests__/unit/services/nativeRuntimeService.guardStop.test.js index 98b686e1eb..aa74374b2f 100644 --- a/backend/__tests__/unit/services/nativeRuntimeService.guardStop.test.js +++ b/backend/__tests__/unit/services/nativeRuntimeService.guardStop.test.js @@ -524,6 +524,8 @@ describe('a hosted turn the guard stops, or that fails', () => { expect(result).toEqual(expect.objectContaining({ status: 'failed', errorKind: 'llm_error' })); expect(savedRun.metered).toBe(false); expect(posted()).toEqual([`Model deepseek-v4-flash could not answer (HTTP 502). ${HOSTED_RUN_FAILED_LINE}`]); + // The model-failure variant is a failed-turn notice too, so it repeats per ask. + expect(AgentMessageService.postMessage.mock.calls[0][0].failureNotice).toBe(true); expect(chatBodies[0].disable_fallbacks).toBe(true); });