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 {