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 {