From 9326d9cdc4f4d64dd35bbf332243fc9bbd0267f0 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:18:44 -0700 Subject: [PATCH] fix(bot-mode): show the current thread and newest unresolved failure Attribute drive-level errors to the thread being drained, not the send that created the queue. Reinsert repeat member failures in recency order so the collapsed activity row cannot show an older sibling failure. Cover both invariants and the repeated-refusal sequence in native Desktop. Thanks to @kvnloo for identifying both review findings. --- apps/desktop/e2e/group-turn-integrity.spec.ts | 19 +++++++++++- .../plugins/hermes-bots/group-chat-view.tsx | 1 + .../hermes-bots/group-hold-status.test.tsx | 20 +++++++++++++ .../plugins/hermes-bots/group-rounds.test.ts | 30 +++++++++++++++++++ .../src/plugins/hermes-bots/group-rounds.ts | 5 +++- 5 files changed, 73 insertions(+), 2 deletions(-) diff --git a/apps/desktop/e2e/group-turn-integrity.spec.ts b/apps/desktop/e2e/group-turn-integrity.spec.ts index 61129949b8..5c3f267064 100644 --- a/apps/desktop/e2e/group-turn-integrity.spec.ts +++ b/apps/desktop/e2e/group-turn-integrity.spec.ts @@ -166,7 +166,7 @@ test('a rejected member turn stays visible when the room settles', async () => { (window as any).__rejected = ((window as any).__rejected || 0) + 1 const reject = () => this.dispatchEvent(new MessageEvent('message', { data: JSON.stringify({ jsonrpc: '2.0', id: frame.id, error: { code: 4003, message: 'Controlled member admission refusal' } }) })) - if ((window as any).__rejected === 1) { (window as any).__releaseRefusal = reject } else { queueMicrotask(reject) } + if ((window as any).__rejected <= 3) { (window as any).__releaseRefusal = reject } else { queueMicrotask(reject) } } else { send.call(this, data) } @@ -191,6 +191,23 @@ test('a rejected member turn stays visible when the room settles', async () => { expect((await publicLog(page)).filter(entry => entry.from !== 'You')).toEqual([]) await expect(page.getByRole('button', { name: /^Activity/ })).toContainText('Programmer hit an error') await page.screenshot({ path: '/tmp/botmode-campaign/lane-a-error-after.png' }) + + // One epoch: A fails, B waits, the user retries A, then B and A fail. + // The retry is explicit and after A's failure, unlike the prequeued sends above. + await groupComposer.fill('@programmer LANE_A_FAILURE recency') + await groupComposer.press('Enter') + await expect.poll(() => page.evaluate(() => (window as any).__rejected)).toBe(2) + await groupComposer.fill('@reviewer LANE_A_FAILURE recency') + await groupComposer.press('Enter') + await page.evaluate(() => (window as any).__releaseRefusal()) + await expect.poll(() => page.evaluate(() => (window as any).__rejected)).toBe(3) + await groupComposer.fill('@programmer LANE_A_FAILURE explicit retry') + await groupComposer.press('Enter') + await page.evaluate(() => (window as any).__releaseRefusal()) + await expect.poll(() => page.evaluate(() => (window as any).__rejected), { timeout: 30000 }).toBe(4) + await expect(page.getByRole('button', { name: 'Stop', exact: true })).toHaveCount(0) + await expect(page.getByRole('button', { name: /^Activity/ })).toContainText('Programmer hit an error') + console.log('CONTROLLED REFUSAL: A failed, B failed, A failed again; newest unresolved summary:', await page.getByRole('button', { name: /^Activity/ }).textContent()) }) test('Stop clears a queued follow-up and a direct mention resumes the held member', async () => { diff --git a/apps/desktop/src/plugins/hermes-bots/group-chat-view.tsx b/apps/desktop/src/plugins/hermes-bots/group-chat-view.tsx index dee5bf8ab1..e0c3442f43 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-chat-view.tsx +++ b/apps/desktop/src/plugins/hermes-bots/group-chat-view.tsx @@ -718,6 +718,7 @@ export function GroupChatWorkspace({ group, members, onBack, visible = true }: G const key = event.member || '' if (event.kind === 'failed' || event.kind === 'timed-out') { + unresolvedFailures.delete(key) unresolvedFailures.set(key, event) } else if (event.kind === 'replied' || event.kind === 'passed' || event.kind === 'delivered') { unresolvedFailures.delete(key) diff --git a/apps/desktop/src/plugins/hermes-bots/group-hold-status.test.tsx b/apps/desktop/src/plugins/hermes-bots/group-hold-status.test.tsx index 352d88638e..d10266321c 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-hold-status.test.tsx +++ b/apps/desktop/src/plugins/hermes-bots/group-hold-status.test.tsx @@ -147,6 +147,26 @@ describe('durable group holds', () => { expect(status.textContent).toContain('builder (laptop)') }) + it('summarizes the most recent unresolved failure after a member fails again', async () => { + const [{ GroupChatWorkspace }, chat, activity] = await Promise.all([ + import('./group-chat-view'), import('./group-chat'), import('./group-activity') + ]) + + chat.$groupChats.set({ Core: { log: [], members: MEMBERS, watermarks: {} } }) + + for (const member of ['research', 'builder', 'research']) { + activity.recordGroupActivity('Core', { kind: 'failed', member, thread: member }) + } + + activity.recordGroupActivity('Core', { kind: 'settled', member: null }) + const view = render() + expect(screen.getByRole('button', { name: /^Activity/ }).textContent).toContain('research hit an error') + + activity.recordGroupActivity('Core', { kind: 'replied', member: 'research' }) + view.rerender() + expect(screen.getByRole('button', { name: /^Activity/ }).textContent).toContain('builder hit an error') + }) + it('projects hydrated holds into the real group workspace', async () => { const [{ GroupChatWorkspace }, chat] = await Promise.all([import('./group-chat-view'), import('./group-chat')]) diff --git a/apps/desktop/src/plugins/hermes-bots/group-rounds.test.ts b/apps/desktop/src/plugins/hermes-bots/group-rounds.test.ts index 3a237d363d..3205998569 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-rounds.test.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-rounds.test.ts @@ -264,6 +264,36 @@ describe('round lifecycle', () => { expect(room.gateway.calls[1].prompt).toMatch(/first[\s\S]*queued same-thread[\s\S]*explicitly retry/) }) + it('attributes a queued drive failure to the thread whose harvest failed', async () => { + let finish!: (reply: string) => void + const held = new Promise(resolve => { finish = resolve }) + const room = await loadRoom({ turn: () => held }) + const first = room.rounds.sendToGroupChat('Failure', MEMBERS.slice(0, 2), '@research first')! + await drain(() => room.gateway.calls.length < 1) + const queued = room.rounds.sendToGroupChat('Failure', MEMBERS.slice(0, 2), '@builder queued')! + const request = host.request as (...args: unknown[]) => Promise + + host.request = (...args: unknown[]) => { + const [method, params] = args as [string, { profile?: string }] + + // JSON-shaped malformed reply text throws while harvesting, after RPC acceptance. + return method === 'session.resume' && params.profile === 'builder' + ? Promise.resolve({ messages: [{ role: 'assistant', text: { toString: 1 } }] }) + : request(...args) + } + + room.chat.updateGroupChat('Failure', state => ({ + ...state, stranded: { builder: { before: 0, thread: first } } + })) + finish('(pass)') + await drain(() => !room.activity.currentGroupActivity('Failure').some(event => event.kind === 'failed')) + expect(first).not.toBe(queued) + expect(room.activity.currentGroupActivity('Failure').filter(event => event.kind === 'failed')).toEqual([ + expect.objectContaining({ member: null, thread: queued }) + ]) + expect(room.chat.$groupChats.get().Failure.running).toBe(false) + }) + it('treats a failed member turn as a pass, not a room error', async () => { const room = await loadRoom({ turn: ({ profile }) => { diff --git a/apps/desktop/src/plugins/hermes-bots/group-rounds.ts b/apps/desktop/src/plugins/hermes-bots/group-rounds.ts index 02ee476d17..a6cad78c98 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-rounds.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-rounds.ts @@ -766,16 +766,19 @@ function queueGroupChatDrive(group: string, members: GroupMember[], thread: stri updateGroupChat(group, room => ({ ...room, epoch: (room.epoch || 0) + 1 })) void (async () => { + let currentThread = thread + try { while (binding.isLive() && drive.pending.size) { const [nextThread, nextMembers] = drive.pending.entries().next().value! + currentThread = nextThread drive.pending.delete(nextThread) updateGroupChat(group, room => ({ ...room, running: true })) await runGroupChatRounds(group, nextMembers, nextThread, drive.failedMembers) } } catch { if (binding.isLive()) { - recordGroupActivity(group, { kind: 'failed', member: null, thread }) + recordGroupActivity(group, { kind: 'failed', member: null, thread: currentThread }) updateGroupChat(group, room => ({ ...room, running: false, turn: null })) } } finally {