From 457e383a7e9ded73f41dfde6b009ab6ed0e1c55b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:03:00 -0700 Subject: [PATCH 1/4] fix(desktop): real stop primitive for group-chat rounds (#91868, #94569) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stopGroupThread(group, thread, members?) is the room's first true cancellation primitive: it bumps the room epoch (the driving loop bails at its next member boundary), sets #93129 holds for every member (no future turns until an explicit release), records a 'stopped' activity event on the new epoch, and sends session.interrupt to the member currently on turn via its own route — previously the plugin issued zero interrupt RPCs, so 'stop' meant waiting out the in-flight model call. The runGroupChatMemberTurnLeased poll loop now abandons a turn whose dispatch epoch went stale WHILE its member is held — the stop signature. An ordinary newer-send epoch bump without a hold still polls to completion so late work keeps landing (#93127 commit check unchanged). --- .../desktop/src/plugins/hermes-bots/plugin.js | 90 +++++- .../tests/group-stop-thread.test.mjs | 294 ++++++++++++++++++ 2 files changed, 382 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/plugins/hermes-bots/tests/group-stop-thread.test.mjs diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index bd00ddb03ccf4..3080cd4cce96b 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -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 = { @@ -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 @@ -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. @@ -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 { @@ -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. diff --git a/apps/desktop/src/plugins/hermes-bots/tests/group-stop-thread.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/group-stop-thread.test.mjs new file mode 100644 index 0000000000000..a8b4630fa12af --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/tests/group-stop-thread.test.mjs @@ -0,0 +1,294 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import vm from 'node:vm' + +// #91868/#94569: a REAL stop path for group-chat rounds. Before +// stopGroupThread, the loop's only cancellation primitives were the epoch +// bump (checked at member boundaries only) and #93129 holds (skip FUTURE +// turns) — the plugin issued ZERO session.interrupt RPCs, so "stop" meant +// "wait for the in-flight member to finish its whole model turn". The +// primitive bumps the epoch, holds every member, interrupts the member ON +// TURN, and the runGroupChatMemberTurnLeased poll loop abandons a turn whose +// epoch went stale while its member is held. + +const pluginSource = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8') + +/** Harness mirroring group-turn-lease.test.mjs: run plugin.js in a vm with a + * scripted gateway. `busyPolls` makes session.resume report the member as + * inflight for the first N polls, so a stop can land mid-turn. */ +function load({ reply = 'long answer', busyPolls = 0, onResumePoll = null } = {}) { + const values = new Map() + const atom = initial => { + const slot = { get: () => values.get(slot), set: value => values.set(slot, value) } + values.set(slot, initial) + return slot + } + + const sessions = new Map() + const runtimeToStored = new Map() + const titleToStored = new Map() + let sessionSequence = 0 + const rpcLog = [] + let resumePolls = 0 + + const resolveSession = (profile, target) => + (stored => (stored ? sessions.get(stored) : null))( + runtimeToStored.get(target) || (sessions.has(target) ? target : titleToStored.get(`${profile}::${target}`)) + ) + + const handle = async (method, params) => { + rpcLog.push({ method, params }) + + if (method === 'session.create') { + sessionSequence += 1 + const stored = `sid-${sessionSequence}` + const runtime = `rt-${sessionSequence}` + const session = { stored, runtime, profile: params.profile, title: params.title, messages: [] } + sessions.set(stored, session) + runtimeToStored.set(runtime, stored) + titleToStored.set(`${params.profile}::${params.title}`, stored) + return { session_id: runtime, stored_session_id: stored, message_count: 0, messages: [] } + } + + if (method === 'session.resume') { + const session = resolveSession(params.profile, params.session_id) + + if (!session) { + const err = new Error(`session not found: ${params.session_id}`) + err.code = 4007 + throw err + } + + sessionSequence += 1 + const runtime = `rt-${sessionSequence}` + session.runtime = runtime + runtimeToStored.set(runtime, session.stored) + + // Post-submit polls: stay "busy" for the first `busyPolls` polls so a + // stop can land mid-turn, then settle. `onResumePoll` lets a test fire + // the stop from inside the poll cadence. + const submitted = session.messages.length > 0 + let busy = false + + if (submitted) { + resumePolls += 1 + busy = resumePolls <= busyPolls + + if (typeof onResumePoll === 'function') { + onResumePoll(resumePolls) + } + } + + return { + session_id: runtime, + session_key: session.stored, + message_count: busy ? 0 : session.messages.length, + messages: busy ? [] : [...session.messages], + inflight: busy, + running: false + } + } + + if (method === 'prompt.submit') { + const session = resolveSession(null, params.session_id) + + if (!session) { + const err = new Error(`session-scoped RPC rejected: ${params.session_id} not in memory`) + err.code = 4001 + throw err + } + + session.messages.push({ role: 'user', content: params.text }) + session.messages.push({ role: 'assistant', content: reply }) + return {} + } + + if (method === 'session.interrupt') { + return { interrupted: true } + } + + return {} + } + + const context = { + atom, + setTimeout: fn => { + fn() + return 0 + }, + clearTimeout: () => undefined, + Date, + console, + PALETTE_AREA: 'palette', + COMPOSER_AREAS: { middleware: 'middleware' }, + document: { getElementById: () => null, createElement: () => ({}), head: { appendChild: () => undefined } }, + host: { + request: async (method, params) => handle(method, params), + requestProfile: async (route, method, params) => handle(method, params), + retainProfile: async () => () => undefined, + state: { profile: { get: () => 'default', listen: () => undefined }, gateway: { listen: () => undefined } }, + notify: () => undefined, + notifyError: () => undefined + } + } + + const source = pluginSource + .replace(/^import\s+\*\s+as\s+sdk\s+from '@hermes\/plugin-sdk'\r?\n/m, '') + .replace(/^import\s+\{[\s\S]*?\}\s+from '@hermes\/plugin-sdk'\r?\n/m, '') + .replace(/^const \{ McpTab, ToolsetConfigPanel \} = sdk\r?\n/m, '') + .replace(/^import .* from 'react'\r?\n/m, '') + .replace(/^import .* from 'react\/jsx-runtime'\r?\n/m, '') + .replace('export default {', 'globalThis.plugin = {') + .concat( + '\nglobalThis.__stop = { stopGroupThread, runGroupChatMemberTurn, $groupChats, $groupActivity, currentGroupActivity, updateGroupChat, GROUP_ACTIVITY_LABELS, GROUP_ACTIVITY_GLYPHS };\n' + ) + vm.runInNewContext(source, context, { filename: 'plugin.js' }) + context.plugin.register({ + storage: { get: () => null, set: () => undefined }, + register: () => undefined + }) + return { + ...context.__stop, + context, + rpcLog, + calls: method => rpcLog.filter(entry => entry.method === method) + } +} + +const MEMBERS = [ + { name: 'alpha', title: '' }, + { name: 'beta', title: '' }, + { name: 'gamma', title: '' } +] + +/** Seed a room mid-round: epoch 3, running, alpha on turn with a live + * session id, nobody held yet. */ +function seedRoom(gc, { turn = 'alpha' } = {}) { + gc.$groupChats.set({ + Room: { + log: [], + watermarks: {}, + sessions: { alpha: 'live-alpha-sid' }, + epoch: 3, + running: true, + turn, + holds: {}, + members: MEMBERS + } + }) +} + +test('stopGroupThread bumps the epoch, clears running/turn, and holds every member', async () => { + const gc = load() + seedRoom(gc) + + await gc.stopGroupThread('Room', 't1', MEMBERS) + + const room = gc.$groupChats.get().Room + assert.equal(room.epoch, 4, 'epoch bumped — the driving loop bails at its next boundary') + assert.equal(room.running, false) + assert.equal(room.turn, null) + + for (const member of MEMBERS) { + assert.ok(room.holds[member.name], `${member.name} is held — no future turn until an explicit release`) + assert.equal(room.holds[member.name].thread, 't1') + } +}) + +test('stopGroupThread interrupts the member ON TURN via its live session', async () => { + const gc = load() + seedRoom(gc, { turn: 'alpha' }) + + await gc.stopGroupThread('Room', 't1', MEMBERS) + + const interrupts = gc.calls('session.interrupt') + assert.equal(interrupts.length, 1, 'exactly one interrupt — the serial loop has one member in flight') + assert.equal(interrupts[0].params.session_id, 'live-alpha-sid') +}) + +test('stopGroupThread with nobody on turn stops the room without any interrupt RPC', async () => { + const gc = load() + seedRoom(gc, { turn: null }) + + await gc.stopGroupThread('Room', 't1', MEMBERS) + + assert.equal(gc.calls('session.interrupt').length, 0) + assert.equal(gc.$groupChats.get().Room.running, false) + assert.equal(gc.$groupChats.get().Room.epoch, 4) +}) + +test('stopGroupThread records a stopped activity event visible in the CURRENT run', async () => { + const gc = load() + seedRoom(gc) + + await gc.stopGroupThread('Room', 't1', MEMBERS) + + const events = gc.currentGroupActivity('Room') + const stopped = events.find(event => event.kind === 'stopped') + assert.ok(stopped, 'stopped event is tagged with the POST-bump epoch, so it survives the epoch filter') + assert.equal(stopped.member, 'You') + assert.equal(stopped.thread, 't1') + // The label comes from the shared GROUP_ACTIVITY_LABELS map (the plugin's + // label pattern) — and stays plain English, no hardcoded localized text. + assert.ok(gc.GROUP_ACTIVITY_LABELS.stopped) + assert.ok(gc.GROUP_ACTIVITY_GLYPHS.stopped) +}) + +test('stopGroupThread falls back to the durable room roster when called without members', async () => { + const gc = load() + seedRoom(gc) + + await gc.stopGroupThread('Room', 't1') + + const room = gc.$groupChats.get().Room + assert.equal(Object.keys(room.holds).length, MEMBERS.length) + assert.equal(gc.calls('session.interrupt').length, 1) +}) + +test('poll loop abandons an in-flight turn once a stop bumps the epoch and holds the member', async () => { + let gcRef = null + const gc = load({ + busyPolls: 50, + onResumePoll: polls => { + // Fire the stop from inside the poll cadence, after the second + // busy poll — exactly the mid-turn click the Stop button produces. + if (polls === 2) { + void gcRef.stopGroupThread('Room', 't1', [{ name: 'helper', title: '' }]) + } + } + }) + gcRef = gc + + const reply = await gc.runGroupChatMemberTurn('Room', { name: 'helper', title: '' }, 'long task', 't1', []) + + assert.equal(reply, null, 'abandoned turn yields no reply') + const postStopPolls = gc.calls('session.resume').length + assert.ok(postStopPolls <= 6, `poll loop exited promptly after the stop, not at the deadline (${postStopPolls} resumes)`) + assert.equal(gc.$groupChats.get().Room.running, false) +}) + +test('an ordinary newer-send epoch bump WITHOUT a hold does not abandon the poll — late work still lands', async () => { + let gcRef = null + const gc = load({ + reply: 'finished anyway', + busyPolls: 3, + onResumePoll: polls => { + if (polls === 1) { + // A newer user send bumps the epoch but holds nobody. The in-flight + // poll must keep going so the finished reply can still be delivered + // (the #93127 commit check decides its fate at the boundary). + gcRef.updateGroupChat('Room', r => { + r.epoch = (r.epoch || 0) + 1 + return r + }) + } + } + }) + gcRef = gc + + const reply = await gc.runGroupChatMemberTurn('Room', { name: 'helper', title: '' }, 'long task', 't1', []) + + assert.equal(reply, 'finished anyway', 'epoch churn alone never abandons a live turn') +}) + From 26714ba3dc8bb7118fb4745ef444bc4c2e214789 Mon Sep 17 00:00:00 2001 From: "Lancaster.Q" Date: Wed, 26 Aug 2026 22:03:52 -0700 Subject: [PATCH 2/4] feat(desktop): Stop button for a running group-chat round (#94570) Salvaged from #94570 (@ShonnQ): the Activity bar gains a Stop button while a round is running (room.running), plus an inline Stop on the expanded 'working' activity row. Rewired from the original per-member session.interrupt spray onto the stopGroupThread primitive so the round loop actually stops (epoch bump + holds + on-turn interrupt) instead of marching to the next member; labels are plain English like the rest of the plugin's UI strings. Co-authored-by: Hermes Agent --- .../desktop/src/plugins/hermes-bots/plugin.js | 74 ++++++++++++++----- 1 file changed, 57 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index 3080cd4cce96b..b15bf442ee095 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -13444,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 ] @@ -13491,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}`) ) From 13974e72259e598bb6083259ca16078723babe99 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:03:57 -0700 Subject: [PATCH 3/4] test(desktop): lock the Stop button to room.running and the stop primitive Source-contract tests (the group-room-ux pattern): the workspace renders the Stop button only while room.running, wires it to stopGroupThread (not the #94570 per-member interrupt spray), and carries no hardcoded CJK label. --- .../hermes-bots/tests/group-stop-thread.test.mjs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/group-stop-thread.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/group-stop-thread.test.mjs index a8b4630fa12af..37bf1365f660a 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/group-stop-thread.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/group-stop-thread.test.mjs @@ -292,3 +292,17 @@ test('an ordinary newer-send epoch bump WITHOUT a hold does not abandon the poll assert.equal(reply, 'finished anyway', 'epoch churn alone never abandons a live turn') }) +test('Stop button: workspace renders it while the room is running and wires it to stopGroupThread', () => { + const workspace = pluginSource.slice(pluginSource.indexOf('function GroupChatWorkspace')) + // Visible while a round is running… + assert.match(workspace, /room\.running\s*\?\s*jsx\('button'/, 'Stop button is gated on room.running') + // …and wired to the real primitive, not a per-member interrupt spray. + assert.match(workspace, /stopGroupThread\(/, 'button calls the stopGroupThread primitive') + assert.doesNotMatch(workspace, /stopAllBots/, 'the #94570 interrupt-only shell was rewired, not kept') +}) + +test('no hardcoded CJK label in the room workspace (the #94570 shell shipped a localized 停止)', () => { + const workspace = pluginSource.slice(pluginSource.indexOf('function GroupChatWorkspace')) + assert.doesNotMatch(workspace, /停止/, 'the #94570 hardcoded label must not survive the salvage') + assert.doesNotMatch(workspace, /[\u4e00-\u9fff]/, 'labels stay in the plugin label pattern — plain English strings') +}) From 87b2f5cb78548e683e2cab29a6ac47258745d16a Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 27 Aug 2026 03:15:22 -0700 Subject: [PATCH 4/4] chore: retrigger CI (zero-job dispatch failure, auto-heal)