Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 145 additions & 19 deletions apps/desktop/src/plugins/hermes-bots/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -514,7 +514,8 @@ const GROUP_ACTIVITY_LABELS = {
settled: 'turn settled',
capped: 'turn stopped at the round/message cap',
delivered: 'delivered a late reply',
held: 'is held (stopped by you) — @mention it or say resume to release'
held: 'is held (stopped by you) — @mention it or say resume to release',
stopped: 'stopped the room — remaining turns are held until resumed'
}

const GROUP_ACTIVITY_GLYPHS = {
Expand All @@ -528,7 +529,8 @@ const GROUP_ACTIVITY_GLYPHS = {
settled: 'check-all',
capped: 'debug-step-over',
delivered: 'mail-read',
held: 'debug-pause'
held: 'debug-pause',
stopped: 'debug-stop'
}

/** Text tone for an activity row: quiet for pass/cancel/settle, accent for
Expand Down Expand Up @@ -7628,6 +7630,11 @@ async function runGroupChatMemberTurnLeased(group, member, prompt, thread, image
return null
}

// #91868/#94569: remember the epoch this turn was dispatched under so the
// poll loop below can tell an explicit stop from ordinary room churn.
const dispatchEpoch = ($groupChats.get()[group] || {}).epoch || 0
const memberKey = groupMemberKey(member)

recordGroupActivity(group, { kind: 'working', member: member.name, thread })

// Baseline: how many messages exist before our submit.
Expand Down Expand Up @@ -7703,6 +7710,18 @@ async function runGroupChatMemberTurnLeased(group, member, prompt, thread, image
while (Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, GROUP_TURN_POLL_MS))

// #91868/#94569: an explicit stop (stopGroupThread) bumped the epoch AND
// held this member — the member's session was interrupted, so nothing is
// coming; abandon the poll instead of grinding until the deadline. Both
// conditions on purpose: an ordinary newer send bumps the epoch WITHOUT
// a hold, and that turn must keep polling so finished work can still be
// delivered (the #93127 commit check decides its fate, not this loop).
const roomDuringPoll = $groupChats.get()[group] || {}

if ((roomDuringPoll.epoch || 0) !== dispatchEpoch && (roomDuringPoll.holds || {})[memberKey]) {
return null
}

let state = null

try {
Expand Down Expand Up @@ -8051,6 +8070,73 @@ function unaddressedGroupMentions(group, members, thread) {
})
}

/** #91868/#94569: the REAL stop path for a group round. The round loop's only
* cancellation primitives were the epoch bump (checked at member boundaries)
* and #93129 holds (skip FUTURE turns) — neither touches the member whose
* model call is in flight RIGHT NOW, so "stop" meant "finish this turn
* first". This primitive does all three legs atomically enough to matter:
*
* 1. Bumps the room epoch — the driving loop bails at its next boundary and
* never selects another member (`isCurrent()` in runGroupChatRounds).
* 2. Sets a #93129 hold for EVERY member — future turns stay skipped until
* the user explicitly releases (resume / @all resume / direct mention),
* the exact contract user-typed "@all stop" already has.
* 3. Sends session.interrupt to the member currently ON TURN (room.turn,
* runtime-only) via its own route, so the in-flight model call actually
* dies instead of grinding to completion in the background. Best-effort:
* an unreachable member still leaves the room stopped — the poll loop's
* staleness check (epoch moved AND member held) abandons the turn.
*
* `members` is the live roster when the caller has one (the workspace);
* falls back to the room's durable roster so a two-arg call still works. */
async function stopGroupThread(group, thread, members = null) {
const room = $groupChats.get()[group] || {}
const roster = Array.isArray(members) && members.length ? members : room.members || []
const turnName = room.turn || null
const stamp = { at: Date.now(), byMessageId: null, thread: thread || null }

updateGroupChat(group, r => {
r.epoch = (r.epoch || 0) + 1
r.running = false
r.turn = null

// Same hold shape applyGroupHoldDirective mints for "@all stop" — the
// held-skip path (watermark consume + 'held' activity note) and every
// release gesture apply unchanged. An existing hold keeps its stamp.
const holds = { ...(r.holds || {}) }

for (const member of roster) {
const key = groupMemberKey(member)

if (key && !holds[key]) {
holds[key] = { ...stamp }
}
}

r.holds = holds
return r
})

// Recorded AFTER the bump so the event is tagged with the new epoch — it
// stays visible as the current run's outcome instead of dropping out of
// view with the superseded run's events.
recordGroupActivity(group, { kind: 'stopped', member: 'You', thread: thread || null })

// Interrupt the member actually mid-turn. room.turn is runtime-only and
// names exactly one member (the loop is serial); a settled room has none.
const onTurn = turnName ? roster.find(member => member?.name === turnName) : null
const sessionId = onTurn ? (room.sessions || {})[groupMemberKey(onTurn)] : null

if (onTurn && sessionId) {
try {
await requestForBot(onTurn, 'session.interrupt', { session_id: sessionId })
} catch {
/* best-effort — the epoch/hold legs above already stopped the room;
the abandoned poll loop exits on its staleness check */
}
}
}

/** Drive one bounded round-robin turn for ONE THREAD. Serial — one member at
* a time. A newer user send bumps the room epoch; this loop notices at the
* next member boundary, bails, and the newest send's own loop takes over.
Expand Down Expand Up @@ -13358,27 +13444,54 @@ function GroupChatWorkspace({ group, members, onBack, visible = true }) {
// Events are epoch-tagged, so a superseded run's history drops out of view.
const activityEvents = currentGroupActivity(group)
const latestActivity = activityEvents.length ? activityEvents[activityEvents.length - 1] : null
// #94570 shell rewired onto the real primitive (#91868/#94569): the button
// must stop the ROUND, not just spray per-member interrupts — without the
// epoch bump + holds the loop marched on to the next member. Thread scope:
// the run being stopped is the one the latest activity belongs to.
const stopRoomRun = async () => {
await stopGroupThread(group, latestActivity?.thread || null, memberDescriptors())
host.notify({ kind: 'success', message: `Stopped ${group} — remaining turns are held until you resume` })
}

const activityPanel = jsxs('div', {
className: 'border-b border-(--ui-stroke-secondary)',
children: [
jsxs('button', {
type: 'button',
'aria-expanded': activityOpen,
'aria-controls': `group-activity:${group}`,
title: activityOpen ? 'Hide room activity' : 'Show room activity',
className:
'flex w-full items-center gap-1.5 px-2.5 py-1 text-left text-[0.7rem] text-(--ui-text-quaternary) transition-colors hover:text-foreground',
onClick: () => setActivityOpen(prev => !prev),
jsxs('div', {
className: 'flex items-center gap-1',
children: [
jsx(Codicon, {
name: activityOpen ? 'chevron-down' : 'chevron-right',
className: 'shrink-0 text-[0.65rem]'
jsxs('button', {
type: 'button',
'aria-expanded': activityOpen,
'aria-controls': `group-activity:${group}`,
title: activityOpen ? 'Hide room activity' : 'Show room activity',
className:
'flex min-w-0 flex-1 items-center gap-1.5 px-2.5 py-1 text-left text-[0.7rem] text-(--ui-text-quaternary) transition-colors hover:text-foreground',
onClick: () => setActivityOpen(prev => !prev),
children: [
jsx(Codicon, {
name: activityOpen ? 'chevron-down' : 'chevron-right',
className: 'shrink-0 text-[0.65rem]'
}),
jsx('span', { className: 'shrink-0 font-medium', children: 'Activity' }),
latestActivity
? jsx('span', {
className: 'min-w-0 flex-1 truncate',
children: `${groupActivityLabel(latestActivity)} · ${relativeTime(latestActivity.at)}`
})
: null
]
}),
jsx('span', { className: 'shrink-0 font-medium', children: 'Activity' }),
latestActivity
? jsx('span', {
className: 'min-w-0 flex-1 truncate',
children: `${groupActivityLabel(latestActivity)} · ${relativeTime(latestActivity.at)}`
room.running
? jsx('button', {
type: 'button',
title: 'Stop this run — interrupts the member on turn and holds the rest',
className:
'inline-flex shrink-0 items-center gap-1 rounded px-1.5 py-0.5 text-[0.7rem] font-medium text-(--ui-accent) transition-colors hover:bg-(--chrome-action-hover)',
onClick: () => void stopRoomRun(),
children: [
jsx(Codicon, { name: 'debug-stop', className: 'shrink-0 text-[0.75rem]' }),
'Stop'
]
})
: null
]
Expand All @@ -13405,7 +13518,20 @@ function GroupChatWorkspace({ group, members, onBack, visible = true }) {
jsx('span', {
className: 'shrink-0 text-[0.625rem] text-(--ui-text-quaternary)',
children: relativeTime(event.at)
})
}),
event.kind === 'working'
? jsx('button', {
type: 'button',
title: 'Stop this run — interrupts the member on turn and holds the rest',
className:
'inline-flex shrink-0 items-center gap-0.5 rounded px-1 py-px text-[0.65rem] font-medium text-(--ui-accent) transition-colors hover:bg-(--chrome-action-hover)',
onClick: () => void stopRoomRun(),
children: [
jsx(Codicon, { name: 'debug-stop', className: 'shrink-0 text-[0.7rem]' }),
'Stop'
]
})
: null
]
}, `${event.at}:${i}`)
)
Expand Down
Loading
Loading